Compare commits

..

10 Commits

Author SHA1 Message Date
openhands 087669601a Add design doc for chat message persistence
Design document for PR 2 of 2: Message Queue with Offline Support

This PR addresses messages being lost when:
- WebSocket is disconnected
- Runtime is starting up
- User submits while reconnecting

See PR 1 (Draft Persistence) for the related draft preservation feature.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 04:13:51 +00:00
Tim O'Farrell 8b8ed5be96 fix: Revert on_conversation_update to load conversation inside method (#13368)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 19:08:04 -06:00
Tim O'Farrell c1328f512d Upgrade the SDK to 1.13.0 (#13365) 2026-03-12 13:28:19 -06:00
Tim O'Farrell e2805dea75 Fix pagination bug in event_service_base.search_events causing duplicate events in exports (#13364)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 12:24:06 -06:00
aivong-openhands 127e611706 Fix GHSA-78cv-mqj4-43f7: Update tornado to 6.5.5 (#13362)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-12 13:22:39 -05:00
Hiep Le a176a135da fix: sdk conversations not appearing in cloud ui (#13296) 2026-03-12 22:23:08 +07:00
Tim O'Farrell ab78d7d6e8 fix: Set correct user context in webhook callbacks based on sandbox owner (#13340)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 09:11:35 -06:00
mamoodi 4eb6e4da09 Release 1.5.0 (#13336) 2026-03-11 14:50:13 -04:00
dependabot[bot] 7e66304746 chore(deps): bump pypdf from 6.7.5 to 6.8.0 (#13348)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 12:09:09 -05:00
Graham Neubig a8b12e8eb8 Remove Common Room sync scripts (#13347)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 10:48:37 -04:00
41 changed files with 2379 additions and 1378 deletions
-1
View File
@@ -50,7 +50,6 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
.pytest_pg_info.json
cover/
# Translations
File diff suppressed because it is too large Load Diff
+35 -91
View File
@@ -2057,7 +2057,7 @@ version = "7.1.0"
description = "A Python library for the Docker Engine API."
optional = false
python-versions = ">=3.8"
groups = ["main", "test"]
groups = ["main"]
files = [
{file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"},
{file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"},
@@ -6190,14 +6190,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.12.0"
version = "1.13.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.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
]
[package.dependencies]
@@ -6214,7 +6214,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "1.4.0"
version = "1.5.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6259,9 +6259,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.3"
openhands-agent-server = "1.12"
openhands-sdk = "1.12"
openhands-tools = "1.12"
openhands-agent-server = "1.13"
openhands-sdk = "1.13"
openhands-tools = "1.13"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
pathspec = ">=0.12.1"
@@ -6315,14 +6315,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
]
[package.dependencies]
@@ -6345,14 +6345,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
]
[package.dependencies]
@@ -11599,14 +11599,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
]
[package.extras]
@@ -11777,7 +11777,7 @@ version = "1.2.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.9"
groups = ["main", "test"]
groups = ["main"]
files = [
{file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"},
{file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"},
@@ -11946,7 +11946,8 @@ version = "311"
description = "Python for Window Extensions"
optional = false
python-versions = "*"
groups = ["main", "test"]
groups = ["main"]
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [
{file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"},
{file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"},
@@ -11969,7 +11970,6 @@ files = [
{file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"},
{file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"},
]
markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", test = "sys_platform == \"win32\""}
[[package]]
name = "pywin32-ctypes"
@@ -12536,7 +12536,7 @@ version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
groups = ["main", "test"]
groups = ["main"]
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -13573,60 +13573,6 @@ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
test = ["pre-commit", "pytest (>=7.0)", "pytest-timeout"]
typing = ["mypy (>=1.6,<2.0)", "traitlets (>=5.11.1)"]
[[package]]
name = "testcontainers"
version = "4.14.1"
description = "Python library for throwaway instances of anything that can run in a Docker container"
optional = false
python-versions = ">=3.10"
groups = ["test"]
files = [
{file = "testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891"},
{file = "testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64"},
]
[package.dependencies]
docker = "*"
python-dotenv = "*"
typing-extensions = "*"
urllib3 = "*"
wrapt = "*"
[package.extras]
arangodb = ["python-arango (>=8,<9)"]
aws = ["boto3 (>=1,<2)", "httpx"]
azurite = ["azure-storage-blob (>=12,<13)"]
chroma = ["chromadb-client (>=1,<2)"]
cosmosdb = ["azure-cosmos (>=4,<5)"]
db2 = ["ibm_db_sa ; platform_machine != \"aarch64\" and platform_machine != \"arm64\"", "sqlalchemy (>=2,<3)"]
generic = ["httpx", "redis (>=7,<8)"]
google = ["google-cloud-datastore (>=2,<3)", "google-cloud-pubsub (>=2,<3)"]
influxdb = ["influxdb (>=5,<6)", "influxdb-client (>=1,<2)"]
k3s = ["kubernetes", "pyyaml (>=6.0.3)"]
keycloak = ["python-keycloak (>=6,<7) ; python_version < \"4.0\""]
localstack = ["boto3 (>=1,<2)"]
mailpit = ["cryptography"]
minio = ["minio (>=7,<8)"]
mongodb = ["pymongo (>=4,<5)"]
mssql = ["pymssql (>=2,<3)", "sqlalchemy (>=2,<3)"]
mysql = ["pymysql[rsa] (>=1,<2)", "sqlalchemy (>=2,<3)"]
nats = ["nats-py (>=2,<3)"]
neo4j = ["neo4j (>=6,<7)"]
openfga = ["openfga-sdk"]
opensearch = ["opensearch-py (>=3,<4) ; python_version < \"4.0\""]
oracle = ["oracledb (>=3,<4)", "sqlalchemy (>=2,<3)"]
oracle-free = ["oracledb (>=3,<4)", "sqlalchemy (>=2,<3)"]
qdrant = ["qdrant-client (>=1,<2)"]
rabbitmq = ["pika (>=1,<2)"]
redis = ["redis (>=7,<8)"]
registry = ["bcrypt (>=5,<6)"]
scylla = ["cassandra-driver (>=3,<4)"]
selenium = ["selenium (>=4,<5)"]
sftp = ["cryptography"]
test-module-import = ["httpx"]
trino = ["trino"]
weaviate = ["weaviate-client (>=4,<5)"]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@@ -13825,24 +13771,22 @@ files = [
[[package]]
name = "tornado"
version = "6.5.4"
version = "6.5.5"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
]
[[package]]
@@ -14154,7 +14098,7 @@ version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev", "test"]
groups = ["main", "dev"]
files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
@@ -14502,7 +14446,7 @@ version = "1.17.3"
description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.8"
groups = ["main", "test"]
groups = ["main"]
files = [
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
@@ -15065,4 +15009,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "736db5eecbeda2d24e8e66f9e1eae6594b98fbadc096923da65f1fa693253ce1"
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
-1
View File
@@ -69,7 +69,6 @@ opencv-python = "*"
pandas = "*"
reportlab = "*"
gevent = ">=24.2.1,<26.0.0"
testcontainers = "*"
[tool.poetry-dynamic-versioning]
enable = true
@@ -334,7 +334,10 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
await super().save_app_conversation_info(info)
# Get current user_id for SAAS metadata
# Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context)
user_id_str = await self.user_context.get_user_id()
if not user_id_str and info.created_by_user_id:
user_id_str = info.created_by_user_id
if user_id_str:
# Convert string user_id to UUID
user_id_uuid = UUID(user_id_str)
-562
View File
@@ -1,562 +0,0 @@
#!/usr/bin/env python3
"""
Common Room Sync
This script queries the database to count conversations created by each user,
then creates or updates a signal in Common Room for each user with their
conversation count.
"""
import asyncio
import logging
import os
import sys
import time
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional, Set
import requests
from sqlalchemy import text
# Add the parent directory to the path so we can import from storage
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from server.auth.token_manager import get_keycloak_admin
from storage.database import get_engine
# Configure logging
logging.basicConfig(
level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('common_room_sync')
# Common Room API configuration
COMMON_ROOM_API_KEY = os.environ.get('COMMON_ROOM_API_KEY')
COMMON_ROOM_DESTINATION_SOURCE_ID = os.environ.get('COMMON_ROOM_DESTINATION_SOURCE_ID')
COMMON_ROOM_API_BASE_URL = 'https://api.commonroom.io/community/v1'
# Sync configuration
BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '100'))
KEYCLOAK_BATCH_SIZE = int(os.environ.get('KEYCLOAK_BATCH_SIZE', '20'))
MAX_RETRIES = int(os.environ.get('MAX_RETRIES', '3'))
INITIAL_BACKOFF_SECONDS = float(os.environ.get('INITIAL_BACKOFF_SECONDS', '1'))
MAX_BACKOFF_SECONDS = float(os.environ.get('MAX_BACKOFF_SECONDS', '60'))
BACKOFF_FACTOR = float(os.environ.get('BACKOFF_FACTOR', '2'))
RATE_LIMIT = float(os.environ.get('RATE_LIMIT', '2')) # Requests per second
class CommonRoomSyncError(Exception):
"""Base exception for Common Room sync errors."""
class DatabaseError(CommonRoomSyncError):
"""Exception for database errors."""
class CommonRoomAPIError(CommonRoomSyncError):
"""Exception for Common Room API errors."""
class KeycloakClientError(CommonRoomSyncError):
"""Exception for Keycloak client errors."""
def get_recent_conversations(minutes: int = 60) -> List[Dict[str, Any]]:
"""Get conversations created in the past N minutes.
Args:
minutes: Number of minutes to look back for new conversations.
Returns:
A list of dictionaries, each containing conversation details.
Raises:
DatabaseError: If the database query fails.
"""
try:
# Use a different syntax for the interval that works with pg8000
query = text("""
SELECT
conversation_id, user_id, title, created_at
FROM
conversation_metadata
WHERE
created_at >= NOW() - (INTERVAL '1 minute' * :minutes)
ORDER BY
created_at DESC
""")
with get_engine().connect() as connection:
result = connection.execute(query, {'minutes': minutes})
conversations = [
{
'conversation_id': row[0],
'user_id': row[1],
'title': row[2],
'created_at': row[3].isoformat() if row[3] else None,
}
for row in result
]
logger.info(
f'Retrieved {len(conversations)} conversations created in the past {minutes} minutes'
)
return conversations
except Exception as e:
logger.exception(f'Error querying recent conversations: {e}')
raise DatabaseError(f'Failed to query recent conversations: {e}')
async def get_users_from_keycloak(user_ids: Set[str]) -> Dict[str, Dict[str, Any]]:
"""Get user information from Keycloak for a set of user IDs.
Args:
user_ids: A set of user IDs to look up.
Returns:
A dictionary mapping user IDs to user information dictionaries.
Raises:
KeycloakClientError: If the Keycloak API call fails.
"""
try:
# Get Keycloak admin client
keycloak_admin = get_keycloak_admin()
# Create a dictionary to store user information
user_info_dict = {}
# Convert set to list for easier batching
user_id_list = list(user_ids)
# Process user IDs in batches
for i in range(0, len(user_id_list), KEYCLOAK_BATCH_SIZE):
batch = user_id_list[i : i + KEYCLOAK_BATCH_SIZE]
batch_tasks = []
# Create tasks for each user ID in the batch
for user_id in batch:
# Use the Keycloak admin client to get user by ID
batch_tasks.append(get_user_by_id(keycloak_admin, user_id))
# Run the batch of tasks concurrently
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
# Process the results
for user_id, result in zip(batch, batch_results):
if isinstance(result, Exception):
logger.warning(f'Error getting user {user_id}: {result}')
continue
if result and isinstance(result, dict):
user_info_dict[user_id] = {
'username': result.get('username'),
'email': result.get('email'),
'id': result.get('id'),
}
logger.info(
f'Retrieved information for {len(user_info_dict)} users from Keycloak'
)
return user_info_dict
except Exception as e:
error_msg = f'Error getting users from Keycloak: {e}'
logger.exception(error_msg)
raise KeycloakClientError(error_msg)
async def get_user_by_id(keycloak_admin, user_id: str) -> Optional[Dict[str, Any]]:
"""Get a user from Keycloak by ID.
Args:
keycloak_admin: The Keycloak admin client.
user_id: The user ID to look up.
Returns:
A dictionary with the user's information, or None if not found.
"""
try:
# Use the Keycloak admin client to get user by ID
user = keycloak_admin.get_user(user_id)
if user:
logger.debug(
f"Found user in Keycloak: {user.get('username')}, {user.get('email')}"
)
return user
else:
logger.warning(f'User {user_id} not found in Keycloak')
return None
except Exception as e:
logger.warning(f'Error getting user {user_id} from Keycloak: {e}')
return None
def get_user_info(
user_id: str, user_info_cache: Dict[str, Dict[str, Any]]
) -> Optional[Dict[str, str]]:
"""Get the email address and GitHub username for a user from the cache.
Args:
user_id: The user ID to look up.
user_info_cache: A dictionary mapping user IDs to user information.
Returns:
A dictionary with the user's email and username, or None if not found.
"""
# Check if the user is in the cache
if user_id in user_info_cache:
user_info = user_info_cache[user_id]
logger.debug(
f"Found user info in cache: {user_info.get('username')}, {user_info.get('email')}"
)
return user_info
else:
logger.warning(f'User {user_id} not found in user info cache')
return None
def register_user_in_common_room(
user_id: str, email: str, github_username: str
) -> Dict[str, Any]:
"""Create or update a user in Common Room.
Args:
user_id: The user ID.
email: The user's email address.
github_username: The user's GitHub username.
Returns:
The API response from Common Room.
Raises:
CommonRoomAPIError: If the Common Room API request fails.
"""
if not COMMON_ROOM_API_KEY:
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
raise CommonRoomAPIError(
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
)
try:
headers = {
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
'Content-Type': 'application/json',
}
# Create or update user in Common Room
user_data = {
'id': user_id,
'email': email,
'username': github_username,
'github': {'type': 'handle', 'value': github_username},
}
user_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/user'
user_response = requests.post(user_url, headers=headers, json=user_data)
if user_response.status_code not in (200, 202):
logger.error(
f'Failed to create/update user in Common Room: {user_response.text}'
)
logger.error(f'Response status code: {user_response.status_code}')
raise CommonRoomAPIError(
f'Failed to create/update user: {user_response.text}'
)
logger.info(
f'Registered/updated user {user_id} (GitHub: {github_username}) in Common Room'
)
return user_response.json()
except requests.RequestException as e:
logger.exception(f'Error communicating with Common Room API: {e}')
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
def register_conversation_activity(
user_id: str,
conversation_id: str,
conversation_title: str,
created_at: datetime,
email: str,
github_username: str,
) -> Dict[str, Any]:
"""Create an activity in Common Room for a new conversation.
Args:
user_id: The user ID who created the conversation.
conversation_id: The ID of the conversation.
conversation_title: The title of the conversation.
created_at: The datetime object when the conversation was created.
email: The user's email address.
github_username: The user's GitHub username.
Returns:
The API response from Common Room.
Raises:
CommonRoomAPIError: If the Common Room API request fails.
"""
if not COMMON_ROOM_API_KEY:
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
raise CommonRoomAPIError(
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
)
try:
headers = {
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
'Content-Type': 'application/json',
}
# Format the datetime object to the expected ISO format
formatted_timestamp = (
created_at.strftime('%Y-%m-%dT%H:%M:%SZ')
if created_at
else time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
)
# Create activity for the conversation
activity_data = {
'id': f'conversation_{conversation_id}', # Use conversation ID to ensure uniqueness
'activityType': 'started_session',
'user': {
'id': user_id,
'email': email,
'github': {'type': 'handle', 'value': github_username},
'username': github_username,
},
'activityTitle': {
'type': 'text',
'value': conversation_title or 'New Conversation',
},
'content': {
'type': 'text',
'value': f'Started a new conversation: {conversation_title or "Untitled"}',
},
'timestamp': formatted_timestamp,
'url': f'https://app.all-hands.dev/conversations/{conversation_id}',
}
# Log the activity data for debugging
logger.info(f'Activity data payload: {activity_data}')
activity_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/activity'
activity_response = requests.post(
activity_url, headers=headers, json=activity_data
)
if activity_response.status_code not in (200, 202):
logger.error(
f'Failed to create activity in Common Room: {activity_response.text}'
)
logger.error(f'Response status code: {activity_response.status_code}')
raise CommonRoomAPIError(
f'Failed to create activity: {activity_response.text}'
)
logger.info(
f'Registered conversation activity for user {user_id}, conversation {conversation_id}'
)
return activity_response.json()
except requests.RequestException as e:
logger.exception(f'Error communicating with Common Room API: {e}')
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
def retry_with_backoff(func, *args, **kwargs):
"""Retry a function with exponential backoff.
Args:
func: The function to retry.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result of the function call.
Raises:
The last exception raised by the function.
"""
backoff = INITIAL_BACKOFF_SECONDS
last_exception = None
for attempt in range(MAX_RETRIES):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
if attempt < MAX_RETRIES - 1:
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
time.sleep(sleep_time)
backoff *= BACKOFF_FACTOR
else:
logger.exception(f'All {MAX_RETRIES} attempts failed')
raise last_exception
async def retry_with_backoff_async(func, *args, **kwargs):
"""Retry an async function with exponential backoff.
Args:
func: The async function to retry.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result of the function call.
Raises:
The last exception raised by the function.
"""
backoff = INITIAL_BACKOFF_SECONDS
last_exception = None
for attempt in range(MAX_RETRIES):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
if attempt < MAX_RETRIES - 1:
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
await asyncio.sleep(sleep_time)
backoff *= BACKOFF_FACTOR
else:
logger.exception(f'All {MAX_RETRIES} attempts failed')
raise last_exception
async def async_sync_recent_conversations_to_common_room(minutes: int = 60):
"""Async main function to sync recent conversations to Common Room.
Args:
minutes: Number of minutes to look back for new conversations.
"""
logger.info(
f'Starting Common Room recent conversations sync (past {minutes} minutes)'
)
stats = {
'total_conversations': 0,
'registered_users': 0,
'registered_activities': 0,
'errors': 0,
'missing_user_info': 0,
}
try:
# Get conversations created in the past N minutes
recent_conversations = retry_with_backoff(get_recent_conversations, minutes)
stats['total_conversations'] = len(recent_conversations)
logger.info(f'Processing {len(recent_conversations)} recent conversations')
if not recent_conversations:
logger.info('No recent conversations found, exiting')
return
# Extract all unique user IDs
user_ids = {conv['user_id'] for conv in recent_conversations if conv['user_id']}
# Get user information for all users in batches
user_info_cache = await retry_with_backoff_async(
get_users_from_keycloak, user_ids
)
# Track registered users to avoid duplicate registrations
registered_users = set()
# Process each conversation
for conversation in recent_conversations:
conversation_id = conversation['conversation_id']
user_id = conversation['user_id']
title = conversation['title']
created_at = conversation[
'created_at'
] # This might be a string or datetime object
try:
# Get user info from cache
user_info = get_user_info(user_id, user_info_cache)
if not user_info:
logger.warning(
f'Could not find user info for user {user_id}, skipping conversation {conversation_id}'
)
stats['missing_user_info'] += 1
continue
email = user_info['email']
github_username = user_info['username']
if not email:
logger.warning(
f'User {user_id} has no email, skipping conversation {conversation_id}'
)
stats['errors'] += 1
continue
# Register user in Common Room if not already registered in this run
if user_id not in registered_users:
register_user_in_common_room(user_id, email, github_username)
registered_users.add(user_id)
stats['registered_users'] += 1
# If created_at is a string, parse it to a datetime object
# If it's already a datetime object, use it as is
# If it's None, use current time
created_at_datetime = (
created_at
if isinstance(created_at, datetime)
else datetime.fromisoformat(created_at.replace('Z', '+00:00'))
if created_at
else datetime.now(UTC)
)
# Register conversation activity with email and github username
register_conversation_activity(
user_id,
conversation_id,
title,
created_at_datetime,
email,
github_username,
)
stats['registered_activities'] += 1
# Sleep to respect rate limit
await asyncio.sleep(1 / RATE_LIMIT)
except Exception as e:
logger.exception(
f'Error processing conversation {conversation_id} for user {user_id}: {e}'
)
stats['errors'] += 1
except Exception as e:
logger.exception(f'Sync failed: {e}')
raise
finally:
logger.info(f'Sync completed. Stats: {stats}')
def sync_recent_conversations_to_common_room(minutes: int = 60):
"""Main function to sync recent conversations to Common Room.
Args:
minutes: Number of minutes to look back for new conversations.
"""
# Run the async function in the event loop
asyncio.run(async_sync_recent_conversations_to_common_room(minutes))
if __name__ == '__main__':
# Default to looking back 60 minutes for new conversations
minutes = int(os.environ.get('SYNC_MINUTES', '60'))
sync_recent_conversations_to_common_room(minutes)
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
"""
Test script for Common Room conversation count sync.
This script tests the functionality of the Common Room sync script
without making any API calls to Common Room or database connections.
"""
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sync.common_room_sync import (
retry_with_backoff,
)
class TestCommonRoomSync(unittest.TestCase):
"""Test cases for Common Room sync functionality."""
def test_retry_with_backoff(self):
"""Test the retry_with_backoff function."""
# Mock function that succeeds on the second attempt
mock_func = MagicMock(
side_effect=[Exception('First attempt failed'), 'success']
)
# Set environment variables for testing
with patch.dict(
os.environ,
{
'MAX_RETRIES': '3',
'INITIAL_BACKOFF_SECONDS': '0.01',
'BACKOFF_FACTOR': '2',
'MAX_BACKOFF_SECONDS': '1',
},
):
result = retry_with_backoff(mock_func, 'arg1', 'arg2', kwarg1='kwarg1')
# Check that the function was called twice
self.assertEqual(mock_func.call_count, 2)
# Check that the function was called with the correct arguments
mock_func.assert_called_with('arg1', 'arg2', kwarg1='kwarg1')
# Check that the function returned the expected result
self.assertEqual(result, 'success')
if __name__ == '__main__':
unittest.main()
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Test script to verify the conversation count query.
This script tests the database query to count conversations by user,
without making any API calls to Common Room.
"""
import os
import sys
from sqlalchemy import text
# Add the parent directory to the path so we can import from storage
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from storage.database import get_engine
def test_conversation_count_query():
"""Test the query to count conversations by user."""
try:
# Query to count conversations by user
count_query = text("""
SELECT
user_id, COUNT(*) as conversation_count
FROM
conversation_metadata
GROUP BY
user_id
""")
engine = get_engine()
with engine.connect() as connection:
count_result = connection.execute(count_query)
user_counts = [
{'user_id': row[0], 'conversation_count': row[1]}
for row in count_result
]
print(f'Found {len(user_counts)} users with conversations')
# Print the first 5 results
for i, user_data in enumerate(user_counts[:5]):
print(
f"User {i+1}: {user_data['user_id']} - {user_data['conversation_count']} conversations"
)
# Test the user_entity query for the first user (if any)
if user_counts:
first_user_id = user_counts[0]['user_id']
user_query = text("""
SELECT username, email, id
FROM user_entity
WHERE id = :user_id
""")
with engine.connect() as connection:
user_result = connection.execute(user_query, {'user_id': first_user_id})
user_row = user_result.fetchone()
if user_row:
print(f'\nUser details for {first_user_id}:')
print(f' GitHub Username: {user_row[0]}')
print(f' Email: {user_row[1]}')
print(f' ID: {user_row[2]}')
else:
print(
f'\nNo user details found for {first_user_id} in user_entity table'
)
print('\nTest completed successfully')
except Exception as e:
print(f'Error: {str(e)}')
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
test_conversation_count_query()
+83 -226
View File
@@ -1,5 +1,3 @@
import json
import pathlib
import uuid
from datetime import datetime
from uuid import UUID
@@ -10,13 +8,15 @@ from server.constants import ORG_SETTINGS_VERSION
from server.verified_models.verified_model_service import (
StoredVerifiedModel, # noqa: F401
)
from sqlalchemy import create_engine, text
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import sessionmaker
# Anything not loaded here may not have a table created for it.
from storage.api_key import ApiKey # noqa: F401
from storage.base import Base
from storage.billing_session import BillingSession
@@ -35,112 +35,6 @@ from storage.stored_conversation_metadata_saas import (
from storage.stored_offline_token import StoredOfflineToken
from storage.stripe_customer import StripeCustomer
from storage.user import User
from testcontainers.postgres import PostgresContainer
from xdist import is_xdist_controller
# ---------------------------------------------------------------------------
# PostgreSQL container lifecycle — managed by the xdist controller
# ---------------------------------------------------------------------------
_PG_INFO_FILE = '.pytest_pg_info.json'
def _pg_info_path(session):
"""Return the path to the shared PG connection info file."""
return pathlib.Path(session.config.rootpath) / _PG_INFO_FILE
def _extract_pg_info(pg):
"""Extract connection info dict from a running PostgresContainer."""
return {
'host': pg.get_container_host_ip(),
'port': pg.get_exposed_port(5432),
'user': pg.username,
'password': pg.password,
'default_dbname': pg.dbname,
}
def _import_all_models():
"""Import every Base subclass so Base.metadata knows about all tables.
These imports are deliberately kept out of module scope because some
transitively load C-extension modules that spawn threads, which makes
the process unsafe to fork() (used by pytest --forked). Importing
them here — inside the controller only — avoids that problem.
"""
import importlib
import logging
import pkgutil
import storage as _storage_pkg
log = logging.getLogger(__name__)
for _importer, modname, _ispkg in pkgutil.walk_packages(
_storage_pkg.__path__, prefix='storage.'
):
try:
importlib.import_module(modname)
except ImportError as e:
log.warning('Failed to import %s: %s', modname, e)
def _create_template_db(info):
"""Create a template database with all tables via Base.metadata.create_all()."""
_import_all_models()
default_url = (
f"postgresql+psycopg2://{info['user']}:{info['password']}"
f"@{info['host']}:{info['port']}/{info['default_dbname']}"
)
default_engine = create_engine(default_url, isolation_level='AUTOCOMMIT')
with default_engine.connect() as conn:
conn.execute(text('CREATE DATABASE template_test'))
default_engine.dispose()
template_url = (
f"postgresql+psycopg2://{info['user']}:{info['password']}"
f"@{info['host']}:{info['port']}/template_test"
)
template_engine = create_engine(template_url)
Base.metadata.create_all(template_engine)
template_engine.dispose()
def pytest_sessionstart(session):
"""Start the PostgreSQL container on the controller and create the template DB.
The controller (or non-xdist process) starts the container, creates the
template database, and writes connection info to a file. Workers read the
file to discover the shared container.
"""
if is_xdist_controller(session) or not hasattr(session.config, 'workerinput'):
# Controller or non-xdist: start container and create template DB
pg = PostgresContainer('postgres:16-alpine')
pg.start()
session.config._pg_container = pg
info = _extract_pg_info(pg)
_create_template_db(info)
_pg_info_path(session).write_text(json.dumps(info))
session.config._pg_info = info
else:
# xdist worker: read connection info written by the controller
session.config._pg_info = json.loads(_pg_info_path(session).read_text())
def pytest_sessionfinish(session, exitstatus):
"""Stop the PostgreSQL container and clean up the info file."""
pg = getattr(session.config, '_pg_container', None)
if pg is not None:
pg.stop()
info_path = _pg_info_path(session)
if info_path.exists():
info_path.unlink()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
@@ -163,71 +57,20 @@ def create_keycloak_user_info():
return _create
@pytest.fixture(scope='session')
def _pg_template_db(request):
"""Return the shared PostgreSQL connection info from config."""
return request.config._pg_info
# ---------------------------------------------------------------------------
# Function-scoped: one cloned database per test
# ---------------------------------------------------------------------------
@pytest.fixture(scope='function')
def _test_db(_pg_template_db):
"""Clone the template database for a single test."""
info = _pg_template_db
db_name = f'test_{uuid.uuid4().hex[:12]}'
default_url = (
f"postgresql+psycopg2://{info['user']}:{info['password']}"
f"@{info['host']}:{info['port']}/{info['default_dbname']}"
)
default_engine = create_engine(default_url, isolation_level='AUTOCOMMIT')
with default_engine.connect() as conn:
conn.execute(text(f'CREATE DATABASE "{db_name}" TEMPLATE template_test'))
default_engine.dispose()
yield {**info, 'dbname': db_name}
# Teardown: terminate connections then drop the test database
default_engine = create_engine(default_url, isolation_level='AUTOCOMMIT')
with default_engine.connect() as conn:
conn.execute(
text(
f'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid()"
)
)
conn.execute(text(f'DROP DATABASE IF EXISTS "{db_name}"'))
default_engine.dispose()
def db_path(tmp_path):
"""Create a unique temp file path for each test."""
return str(tmp_path / 'test.db')
@pytest.fixture
def engine(_test_db):
"""Create a sync engine pointing at the per-test PostgreSQL database."""
info = _test_db
url = (
f"postgresql+psycopg2://{info['user']}:{info['password']}"
f"@{info['host']}:{info['port']}/{info['dbname']}"
def engine(db_path):
"""Create a sync engine with tables using file-based DB."""
engine = create_engine(
f'sqlite:///{db_path}', connect_args={'check_same_thread': False}
)
eng = create_engine(url)
yield eng
eng.dispose()
@pytest.fixture
async def async_engine(_test_db):
"""Create an async engine pointing at the per-test PostgreSQL database."""
info = _test_db
url = (
f"postgresql+asyncpg://{info['user']}:{info['password']}"
f"@{info['host']}:{info['port']}/{info['dbname']}"
)
eng = create_async_engine(url)
yield eng
await eng.dispose()
Base.metadata.create_all(engine)
return engine
@pytest.fixture
@@ -235,74 +78,38 @@ def session_maker(engine):
return sessionmaker(bind=engine)
@pytest.fixture
def async_engine(db_path):
"""Create an async engine using the SAME file-based database."""
async_engine = create_async_engine(
f'sqlite+aiosqlite:///{db_path}',
connect_args={'check_same_thread': False},
)
async def create_tables():
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Run the async function synchronously
import asyncio
asyncio.run(create_tables())
return async_engine
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker bound to the async engine."""
return async_sessionmaker(
async_session_maker = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
return async_session_maker
def add_minimal_fixtures(session_maker):
with session_maker() as session:
# Insert FK parent rows first: Org, Role, User
session.add(
Org(
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
name='mock-org',
org_version=ORG_SETTINGS_VERSION,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
)
session.add(
Role(
id=1,
name='admin',
rank=1,
)
)
session.flush()
session.add(
User(
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
current_org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
user_consents_to_analytics=True,
)
)
session.flush()
# Now insert rows that depend on Org/Role/User
session.add(
OrgMember(
org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
user_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
role_id=1,
llm_api_key='mock-api-key',
status='active',
)
)
session.add(
StoredConversationMetadata(
conversation_id='mock-conversation-id',
created_at=datetime.fromisoformat('2025-03-07'),
last_updated_at=datetime.fromisoformat('2025-03-08'),
accumulated_cost=5.25,
prompt_tokens=500,
completion_tokens=250,
total_tokens=750,
)
)
session.flush()
session.add(
StoredConversationMetadataSaas(
conversation_id='mock-conversation-id',
user_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
org_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
)
)
session.add(
BillingSession(
id='mock-billing-session-id',
@@ -332,6 +139,24 @@ def add_minimal_fixtures(session_maker):
updated_at=datetime.fromisoformat('2025-03-06'),
)
)
session.add(
StoredConversationMetadata(
conversation_id='mock-conversation-id',
created_at=datetime.fromisoformat('2025-03-07'),
last_updated_at=datetime.fromisoformat('2025-03-08'),
accumulated_cost=5.25,
prompt_tokens=500,
completion_tokens=250,
total_tokens=750,
)
)
session.add(
StoredConversationMetadataSaas(
conversation_id='mock-conversation-id',
user_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
org_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
)
)
session.add(
StoredOfflineToken(
user_id='mock-user-id',
@@ -340,6 +165,38 @@ def add_minimal_fixtures(session_maker):
updated_at=datetime.fromisoformat('2025-03-08'),
)
)
session.add(
Org(
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
name='mock-org',
org_version=ORG_SETTINGS_VERSION,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
)
session.add(
Role(
id=1,
name='admin',
rank=1,
)
)
session.add(
User(
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
current_org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
user_consents_to_analytics=True,
)
)
session.add(
OrgMember(
org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
user_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
role_id=1,
llm_api_key='mock-api-key',
status='active',
)
)
session.add(
StripeCustomer(
keycloak_user_id='mock-user-id',
@@ -1,20 +1,48 @@
"""Unit tests for AuthTokenStore using PostgreSQL via testcontainers."""
"""Unit tests for AuthTokenStore using SQLite in-memory database."""
import time
from unittest.mock import patch
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.auth_token_store import (
ACCESS_TOKEN_EXPIRY_BUFFER,
LOCK_TIMEOUT_SECONDS,
AuthTokenStore,
)
from storage.auth_tokens import AuthTokens
from storage.base import Base
from openhands.integrations.service_types import ProviderType
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
)
return engine
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker bound to the async engine."""
async_session_maker = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Create all tables
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return async_session_maker
class TestIsTokenExpired:
"""Tests for _is_token_expired method."""
@@ -3,9 +3,56 @@
import pytest
from integrations.types import GitLabResourceType
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.gitlab_webhook import GitlabWebhook
from storage.gitlab_webhook_store import GitlabWebhookStore
# Use module-scoped engine to share database across fixtures
_test_engine = None
@pytest.fixture(scope='function')
def event_loop():
"""Create an instance of the default event loop for each test case."""
import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope='function')
async def async_engine(event_loop):
"""Create an async SQLite engine for testing.
This fixture creates an in-memory SQLite database and ensures
all tables are created before tests run.
"""
global _test_engine
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
_test_engine = engine
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture(scope='function')
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture
async def webhook_store(async_session_maker):
@@ -8,11 +8,35 @@ import uuid
import pytest
from server.routes.org_models import OrgAppSettingsUpdate
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.org import Org
from storage.org_app_settings_store import OrgAppSettingsStore
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_current_org_by_user_id_success(async_session_maker):
"""
@@ -9,11 +9,35 @@ from unittest.mock import AsyncMock, patch
import pytest
from server.routes.org_models import OrgLLMSettingsUpdate
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.org import Org
from storage.org_llm_settings_store import OrgLLMSettingsStore
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_current_org_by_user_id_success(async_session_maker):
"""
@@ -14,7 +14,9 @@ from server.utils.saas_app_conversation_info_injector import (
SaasSQLAppConversationInfoService,
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.org import Org
from storage.user import User
@@ -33,17 +35,41 @@ ORG2_ID = UUID('d2222222-2222-2222-2222-222222222222')
@pytest.fixture
async def async_session(async_session_maker) -> AsyncGenerator[AsyncSession, None]:
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create an async session for testing."""
async_session_maker = async_sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session_maker() as db_session:
yield db_session
@pytest.fixture
async def async_session_with_users(
async_session_maker,
) -> AsyncGenerator[AsyncSession, None]:
async def async_session_with_users(async_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create an async session with pre-populated Org and User rows for testing."""
async_session_maker = async_sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session_maker() as db_session:
# Insert Orgs first (required for User foreign key)
@@ -637,3 +663,131 @@ class TestSaasSQLAppConversationInfoServiceAdminContext:
admin_page = await admin_service.search_app_conversation_info()
assert len(admin_page.items) == 5
class TestSaasSQLAppConversationInfoServiceWebhookFallback:
"""Test suite for webhook callback fallback using info.created_by_user_id."""
@pytest.mark.asyncio
async def test_save_with_admin_context_uses_created_by_user_id_fallback(
self,
async_session_with_users: AsyncSession,
):
"""Test that save_app_conversation_info uses info.created_by_user_id when user_context returns None.
This is the key fix for SDK-created conversations: when the webhook endpoint
uses ADMIN context (user_id=None), the service should fall back to using
the created_by_user_id from the AppConversationInfo object.
"""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Create service with ADMIN context (user_id=None)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Create conversation info with created_by_user_id set (as would come from sandbox_info)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID), # This should be used as fallback
sandbox_id='sandbox_webhook_test',
title='Webhook Created Conversation',
)
# Act: Save using ADMIN context
await admin_service.save_app_conversation_info(conv_info)
# Assert: SAAS metadata should be created with user_id from info.created_by_user_id
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None, 'SAAS metadata should be created'
assert (
saas_metadata.user_id == USER1_ID
), 'user_id should match info.created_by_user_id'
assert saas_metadata.org_id == ORG1_ID, 'org_id should match user current org'
@pytest.mark.asyncio
async def test_save_with_admin_context_no_user_id_skips_saas_metadata(
self,
async_session_with_users: AsyncSession,
):
"""Test that save_app_conversation_info skips SAAS metadata when both user_context and info have no user_id."""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Create service with ADMIN context (user_id=None)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Create conversation info without created_by_user_id
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=None, # No user_id available
sandbox_id='sandbox_no_user',
title='No User Conversation',
)
# Act: Save using ADMIN context with no user_id fallback
await admin_service.save_app_conversation_info(conv_info)
# Assert: SAAS metadata should NOT be created
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert (
saas_metadata is None
), 'SAAS metadata should not be created without user_id'
@pytest.mark.asyncio
async def test_webhook_created_conversation_visible_to_user(
self,
async_session_with_users: AsyncSession,
):
"""Test end-to-end: conversation saved via webhook is visible to the owning user."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Save conversation using ADMIN context (simulating webhook)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_webhook_e2e',
title='E2E Webhook Conversation',
)
await admin_service.save_app_conversation_info(conv_info)
# Act: Query as the owning user
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
user1_page = await user1_service.search_app_conversation_info()
# Assert: User should see the webhook-created conversation
assert len(user1_page.items) == 1
assert user1_page.items[0].id == conv_id
assert user1_page.items[0].title == 'E2E Webhook Conversation'
@@ -8,11 +8,35 @@ import uuid
import pytest
from server.routes.user_app_settings_models import UserAppSettingsUpdate
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.org import Org
from storage.user import User
from storage.user_app_settings_store import UserAppSettingsStore
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_user_by_id_success(async_session_maker):
"""
@@ -1,12 +1,39 @@
"""Unit tests for UserAuthorizationStore using PostgreSQL via testcontainers."""
"""Unit tests for UserAuthorizationStore using SQLite in-memory database."""
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.user_authorization import UserAuthorization, UserAuthorizationType
from storage.user_authorization_store import UserAuthorizationStore
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
)
return engine
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker bound to the async engine."""
session_maker = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return session_maker
class TestGetMatchingAuthorizations:
"""Tests for get_matching_authorizations method."""
+25 -36
View File
@@ -6,24 +6,13 @@ import pytest
from sqlalchemy import select
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.org import Org
@pytest.fixture
async def test_org(async_session_maker):
"""Create a test Org in the database for FK constraints."""
_id = uuid.uuid4()
async with async_session_maker() as session:
session.add(Org(id=_id, name='test-org'))
await session.commit()
return _id
@pytest.fixture
async def mock_user(test_org):
"""Mock user with org_id backed by a real Org record."""
def mock_user():
"""Mock user with org_id."""
user = MagicMock()
user.current_org_id = test_org
user.current_org_id = uuid.uuid4()
return user
@@ -120,11 +109,11 @@ async def test_create_api_key(
@pytest.mark.asyncio
async def test_validate_api_key_valid(api_key_store, async_session_maker, test_org):
async def test_validate_api_key_valid(api_key_store, async_session_maker):
"""Test validating a valid API key."""
# Setup - create an API key in the database
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
api_key_value = 'test-api-key'
async with async_session_maker() as session:
@@ -147,11 +136,11 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker, test_o
@pytest.mark.asyncio
async def test_validate_api_key_expired(api_key_store, async_session_maker, test_org):
async def test_validate_api_key_expired(api_key_store, async_session_maker):
"""Test validating an expired API key."""
# Setup - create an expired API key in the database
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
api_key_value = 'test-expired-key'
async with async_session_maker() as session:
@@ -160,7 +149,7 @@ async def test_validate_api_key_expired(api_key_store, async_session_maker, test
user_id=user_id,
org_id=org_id,
name='Test Key',
expires_at=datetime.now() - timedelta(days=1),
expires_at=datetime.now(UTC) - timedelta(days=1),
)
session.add(key_record)
await session.commit()
@@ -175,12 +164,12 @@ async def test_validate_api_key_expired(api_key_store, async_session_maker, test
@pytest.mark.asyncio
async def test_validate_api_key_expired_timezone_naive(
api_key_store, async_session_maker, test_org
api_key_store, async_session_maker
):
"""Test validating an expired API key with timezone-naive datetime from database."""
# Setup - create an expired API key with timezone-naive datetime
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
api_key_value = 'test-expired-naive-key'
async with async_session_maker() as session:
@@ -205,12 +194,12 @@ async def test_validate_api_key_expired_timezone_naive(
@pytest.mark.asyncio
async def test_validate_api_key_valid_timezone_naive(
api_key_store, async_session_maker, test_org
api_key_store, async_session_maker
):
"""Test validating a valid API key with timezone-naive datetime from database."""
# Setup - create a valid API key with timezone-naive datetime (future date)
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
api_key_value = 'test-valid-naive-key'
async with async_session_maker() as session:
@@ -246,12 +235,12 @@ async def test_validate_api_key_not_found(api_key_store, async_session_maker):
@pytest.mark.asyncio
async def test_validate_api_key_stores_timezone_naive_last_used_at(
api_key_store, async_session_maker, test_org
api_key_store, async_session_maker
):
"""Test that validate_api_key stores a timezone-naive datetime for last_used_at."""
# Arrange
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
api_key_value = 'test-timezone-naive-key'
async with async_session_maker() as session:
@@ -280,11 +269,11 @@ async def test_validate_api_key_stores_timezone_naive_last_used_at(
@pytest.mark.asyncio
async def test_delete_api_key(api_key_store, async_session_maker, test_org):
async def test_delete_api_key(api_key_store, async_session_maker):
"""Test deleting an API key."""
# Setup - create an API key in the database
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
api_key_value = 'test-delete-key'
async with async_session_maker() as session:
@@ -325,11 +314,11 @@ async def test_delete_api_key_not_found(api_key_store, async_session_maker):
@pytest.mark.asyncio
async def test_delete_api_key_by_id(api_key_store, async_session_maker, test_org):
async def test_delete_api_key_by_id(api_key_store, async_session_maker):
"""Test deleting an API key by ID."""
# Setup - create an API key in the database
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
async with async_session_maker() as session:
key_record = ApiKey(
@@ -365,7 +354,7 @@ async def test_list_api_keys(
# Setup
user_id = str(uuid.uuid4())
mock_get_user.return_value = mock_user
now = datetime.now()
now = datetime.now(UTC)
# Create API keys in the database
async with async_session_maker() as session:
@@ -418,7 +407,7 @@ async def test_retrieve_mcp_api_key(
# Setup
user_id = str(uuid.uuid4())
mock_get_user.return_value = mock_user
now = datetime.now()
now = datetime.now(UTC)
# Create API keys in the database
async with async_session_maker() as session:
@@ -457,7 +446,7 @@ async def test_retrieve_mcp_api_key_not_found(
# Setup
user_id = str(uuid.uuid4())
mock_get_user.return_value = mock_user
now = datetime.now()
now = datetime.now(UTC)
# Create only non-MCP keys in the database
async with async_session_maker() as session:
@@ -481,11 +470,11 @@ async def test_retrieve_mcp_api_key_not_found(
@pytest.mark.asyncio
async def test_retrieve_api_key_by_name(api_key_store, async_session_maker, test_org):
async def test_retrieve_api_key_by_name(api_key_store, async_session_maker):
"""Test retrieving an API key by name."""
# Setup
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
key_name = 'Test Key'
key_value = 'test-key-by-name'
@@ -521,11 +510,11 @@ async def test_retrieve_api_key_by_name_not_found(api_key_store, async_session_m
@pytest.mark.asyncio
async def test_delete_api_key_by_name(api_key_store, async_session_maker, test_org):
async def test_delete_api_key_by_name(api_key_store, async_session_maker):
"""Test deleting an API key by name."""
# Setup
user_id = str(uuid.uuid4())
org_id = test_org
org_id = uuid.uuid4()
key_name = 'Test Key to Delete'
key_value = 'test-delete-by-name'
@@ -83,19 +83,6 @@ class TestConversationCallback:
def conversation_metadata(self, session_maker):
"""Create a test conversation metadata record."""
with session_maker() as session:
# Create FK parents first
from storage.org import Org
from storage.user import User
org = Org(id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'), name='test-org')
session.add(org)
session.flush()
user = User(
id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'), current_org_id=org.id
)
session.add(user)
session.flush()
metadata = StoredConversationMetadata(
conversation_id='test_conversation_123'
)
+16 -6
View File
@@ -4,12 +4,27 @@ Test that the models are correctly defined.
from uuid import uuid4
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from storage.base import Base
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
from storage.user import User
@pytest.fixture
def engine():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)
return engine
@pytest.fixture
def session_maker(engine):
return sessionmaker(bind=engine)
def test_user_model(session_maker):
"""Test that the User model works correctly."""
with session_maker() as session:
@@ -24,11 +39,6 @@ def test_user_model(session_maker):
session.add(user)
session.flush()
# Create role (FK parent for OrgMember)
role = Role(id=1, name='admin', rank=1)
session.add(role)
session.flush()
# Create org_member relationship
org_member = OrgMember(
org_id=org.id,
@@ -2,6 +2,9 @@ import uuid
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.org import Org
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
@@ -9,6 +12,31 @@ from storage.role import Role
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_org_members(async_session_maker):
# Test getting org_members by org ID
@@ -124,9 +124,6 @@ async def test_create_org_with_owner_success(
# Create user in database first
with session_maker() as session:
org = Org(id=temp_org_id, name='temp-org')
session.add(org)
session.flush()
user = User(id=user_id, current_org_id=temp_org_id)
session.add(user)
session.commit()
+7 -24
View File
@@ -229,10 +229,7 @@ async def test_persist_org_with_owner_success(async_session_maker, mock_litellm_
# Create user and role first
async with async_session_maker() as session:
placeholder_org = Org(name='placeholder')
session.add(placeholder_org)
await session.flush()
user = User(id=user_id, current_org_id=placeholder_org.id)
user = User(id=user_id, current_org_id=org_id)
role = Role(id=1, name='owner', rank=1)
session.add(user)
session.add(role)
@@ -291,10 +288,7 @@ async def test_persist_org_with_owner_returns_refreshed_org(
user_id = uuid.uuid4()
async with async_session_maker() as session:
placeholder_org = Org(name='placeholder')
session.add(placeholder_org)
await session.flush()
user = User(id=user_id, current_org_id=placeholder_org.id)
user = User(id=user_id, current_org_id=org_id)
role = Role(id=1, name='owner', rank=1)
session.add(user)
session.add(role)
@@ -342,10 +336,7 @@ async def test_persist_org_with_owner_transaction_atomicity(
user_id = uuid.uuid4()
async with async_session_maker() as session:
placeholder_org = Org(name='placeholder')
session.add(placeholder_org)
await session.flush()
user = User(id=user_id, current_org_id=placeholder_org.id)
user = User(id=user_id, current_org_id=org_id)
role = Role(id=1, name='owner', rank=1)
session.add(user)
session.add(role)
@@ -398,10 +389,7 @@ async def test_persist_org_with_owner_with_multiple_fields(
user_id = uuid.uuid4()
async with async_session_maker() as session:
placeholder_org = Org(name='placeholder')
session.add(placeholder_org)
await session.flush()
user = User(id=user_id, current_org_id=placeholder_org.id)
user = User(id=user_id, current_org_id=org_id)
role = Role(id=1, name='owner', rank=1)
session.add(user)
session.add(role)
@@ -523,19 +511,14 @@ async def test_delete_org_cascade_litellm_failure_causes_rollback(
user_id = uuid.uuid4()
async with async_session_maker() as session:
role = Role(id=1, name='owner', rank=1)
user = User(id=user_id, current_org_id=org_id)
org = Org(
id=org_id,
name='Test Organization',
contact_name='John Doe',
contact_email='john@example.com',
)
role = Role(id=1, name='owner', rank=1)
session.add(org)
session.add(role)
await session.flush()
user = User(id=user_id, current_org_id=org_id)
session.add(user)
await session.flush()
org_member = OrgMember(
org_id=org_id,
user_id=user_id,
@@ -543,7 +526,7 @@ async def test_delete_org_cascade_litellm_failure_causes_rollback(
status='active',
llm_api_key='test-key',
)
session.add(org_member)
session.add_all([role, user, org, org_member])
await session.commit()
# Mock delete_org_cascade to simulate LiteLLM failure
+28
View File
@@ -1,10 +1,38 @@
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.role import Role
from storage.role_store import RoleStore
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_role_by_id_with_session(async_session_maker):
"""Test getting role by ID with an explicit session."""
@@ -34,11 +34,11 @@ def mock_user_store():
@pytest.mark.asyncio
async def test_save_and_get(session_maker_with_minimal_fixtures):
async def test_save_and_get(session_maker):
store = SaasConversationStore(
'5594c7b6-f959-4b81-92e9-b09c206f5081',
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
session_maker_with_minimal_fixtures,
session_maker,
)
metadata = ConversationMetadata(
conversation_id='my-conversation-id',
@@ -63,11 +63,11 @@ async def test_save_and_get(session_maker_with_minimal_fixtures):
@pytest.mark.asyncio
async def test_search(session_maker_with_minimal_fixtures):
async def test_search(session_maker):
store = SaasConversationStore(
'5594c7b6-f959-4b81-92e9-b09c206f5081',
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
session_maker_with_minimal_fixtures,
session_maker,
)
# Create test conversations with different timestamps
@@ -88,12 +88,9 @@ async def test_search(session_maker_with_minimal_fixtures):
await store.save_metadata(conv)
# Test basic search - should return all valid conversations sorted by created_at
# Note: session_maker_with_minimal_fixtures pre-populates a 'mock-conversation-id'
# record, so we expect 6 results (1 fixture + 5 test conversations)
result = await store.search(limit=10)
assert len(result.results) == 6
assert len(result.results) == 5
assert [c.conversation_id for c in result.results] == [
'mock-conversation-id',
'conv-4',
'conv-3',
'conv-2',
@@ -105,24 +102,21 @@ async def test_search(session_maker_with_minimal_fixtures):
# Test pagination
result = await store.search(limit=2)
assert len(result.results) == 2
assert [c.conversation_id for c in result.results] == [
'mock-conversation-id',
'conv-4',
]
assert [c.conversation_id for c in result.results] == ['conv-4', 'conv-3']
assert result.next_page_id is not None
# Test next page
result = await store.search(page_id=result.next_page_id, limit=2)
assert len(result.results) == 2
assert [c.conversation_id for c in result.results] == ['conv-3', 'conv-2']
assert [c.conversation_id for c in result.results] == ['conv-2', 'conv-1']
@pytest.mark.asyncio
async def test_delete_metadata(session_maker_with_minimal_fixtures):
async def test_delete_metadata(session_maker):
store = SaasConversationStore(
'5594c7b6-f959-4b81-92e9-b09c206f5081',
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
session_maker_with_minimal_fixtures,
session_maker,
)
metadata = ConversationMetadata(
conversation_id='to-delete',
@@ -142,22 +136,22 @@ async def test_delete_metadata(session_maker_with_minimal_fixtures):
@pytest.mark.asyncio
async def test_get_nonexistent_metadata(session_maker_with_minimal_fixtures):
async def test_get_nonexistent_metadata(session_maker):
store = SaasConversationStore(
'5594c7b6-f959-4b81-92e9-b09c206f5081',
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
session_maker_with_minimal_fixtures,
session_maker,
)
with pytest.raises(FileNotFoundError):
await store.get_metadata('nonexistent-id')
@pytest.mark.asyncio
async def test_exists(session_maker_with_minimal_fixtures):
async def test_exists(session_maker):
store = SaasConversationStore(
'5594c7b6-f959-4b81-92e9-b09c206f5081',
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
session_maker_with_minimal_fixtures,
session_maker,
)
metadata = ConversationMetadata(
conversation_id='exists-test',
@@ -5,7 +5,6 @@ from uuid import UUID
import pytest
from pydantic import SecretStr
from storage.org import Org
from storage.saas_secrets_store import SaasSecretsStore
from storage.stored_custom_secrets import StoredCustomSecrets
@@ -30,12 +29,7 @@ def mock_user():
@pytest.fixture
def secrets_store(async_session_maker, session_maker, mock_config, mock_user):
# Create Org for mock_user's org_id to satisfy FK constraints
with session_maker() as session:
session.add(Org(id=mock_user.current_org_id, name='test-org'))
session.commit()
def secrets_store(async_session_maker, mock_config):
# Inject the test session maker into the store module
import storage.saas_secrets_store as store_module
@@ -11,7 +11,8 @@ from server.sharing.shared_conversation_models import (
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.org import Org
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from storage.user import User
@@ -23,6 +24,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
SQLAppConversationInfoService,
)
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
from openhands.app_server.utils.sql_utils import Base
from openhands.integrations.provider import ProviderType
from openhands.sdk.llm import MetricsSnapshot
from openhands.sdk.llm.utils.metrics import TokenUsage
@@ -30,8 +32,31 @@ from openhands.storage.data_models.conversation_metadata import ConversationTrig
@pytest.fixture
async def async_session(async_session_maker) -> AsyncGenerator[AsyncSession, None]:
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create an async session for testing."""
async_session_maker = async_sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session_maker() as db_session:
yield db_session
@@ -415,11 +440,31 @@ class TestSharedConversationInfoServiceWithSaasMetadata:
the conversation_metadata_saas table when it exists.
"""
@pytest.fixture
async def async_engine_with_saas(self):
"""Create an async SQLite engine with all SAAS tables."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_with_saas(
self, async_session_maker
self, async_engine_with_saas
) -> AsyncGenerator[AsyncSession, None]:
"""Create an async session for testing with SAAS tables."""
async_session_maker = async_sessionmaker(
async_engine_with_saas, class_=AsyncSession, expire_on_commit=False
)
async with async_session_maker() as db_session:
yield db_session
@@ -27,12 +27,10 @@ def add_test_org_and_user(session_maker):
from storage.user import User
with session_maker() as session:
# Use existing role from minimal fixtures (id=1, 'admin')
role = session.query(Role).filter(Role.id == 1).first()
if not role:
role = Role(name='test-role', rank=1)
session.add(role)
session.flush()
# Create role first
role = Role(name='test-role', rank=1)
session.add(role)
session.flush()
# Create org
org = Org(id=test_org_id, name='test-org', contact_email='testy@tester.com')
+2 -3
View File
@@ -1236,11 +1236,10 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker):
assert len(saas_rows) == 3, 'All 3 conversations should be migrated'
# Verify the user_id and org_id values
# PostgreSQL returns UUID types from UUID columns, so compare with str()
for row in saas_rows:
assert (
str(row.user_id) == user_uuid_str
row.user_id == user_uuid_str
), f'user_id should match: {row.user_id} vs {user_uuid_str}'
assert (
str(row.org_id) == user_uuid_str
row.org_id == user_uuid_str
), f'org_id should match: {row.org_id} vs {user_uuid_str}'
@@ -4,6 +4,34 @@ import pytest
from server.verified_models.verified_model_service import (
VerifiedModelService,
)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "1.4.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "1.4.0",
"version": "1.5.0",
"dependencies": {
"@heroui/react": "2.8.7",
"@microlink/react-json-view": "^1.27.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"type": "module",
"engines": {
@@ -106,14 +106,15 @@ class EventServiceBase(EventService, ABC):
reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC),
)
# Apply pagination to items (not paths)
start_offset = 0
next_page_id = None
if page_id:
start_offset = int(page_id)
paths = paths[start_offset:]
if len(paths) > limit:
paths = paths[:limit]
items = items[start_offset:]
if len(items) > limit:
next_page_id = str(start_offset + limit)
items = items[:limit]
return EventPage(items=items, next_page_id=next_page_id)
@@ -6,7 +6,7 @@ import logging
import pkgutil
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import APIKeyHeader
from jwt import InvalidTokenError
from pydantic import SecretStr
@@ -23,61 +23,87 @@ from openhands.app_server.config import (
depends_app_conversation_info_service,
depends_event_service,
depends_jwt_service,
depends_sandbox_service,
get_event_callback_service,
get_global_config,
get_sandbox_service,
)
from openhands.app_server.errors import AuthError
from openhands.app_server.event.event_service import EventService
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
SpecifyUserContext,
as_admin,
)
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.sdk import ConversationExecutionStatus, Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.server.types import AppMode
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
from openhands.server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
sandbox_service_dependency = depends_sandbox_service()
event_service_dependency = depends_event_service()
app_conversation_info_service_dependency = depends_app_conversation_info_service()
jwt_dependency = depends_jwt_service()
app_mode = get_global_config().app_mode
_logger = logging.getLogger(__name__)
async def valid_sandbox(
user_context: UserContext = Depends(as_admin),
request: Request,
session_api_key: str = Depends(
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
),
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
"""Use a session api key for validation, and get a sandbox. Subsequent actions
are executed in the context of the owner of the sandbox"""
if not session_api_key:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
)
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
if sandbox_info is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
# Create a state which will be used internally only for this operation
state = InjectorState()
# Since we need access to all sandboxes, this is executed in the context of the admin.
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with get_sandbox_service(state) as sandbox_service:
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
return sandbox_info
if sandbox_info is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
)
# In SAAS Mode there is always a user, so we set the owner of the sandbox
# as the current user (Validated by the session_api_key they provided)
if sandbox_info.created_by_user_id:
setattr(
request.state,
USER_CONTEXT_ATTR,
SpecifyUserContext(sandbox_info.created_by_user_id),
)
elif app_mode == AppMode.SAAS:
_logger.error(
'Sandbox had no user specified', extra={'sandbox_id': sandbox_info.id}
)
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Sandbox had no user specified'
)
return sandbox_info
async def valid_conversation(
conversation_id: UUID,
sandbox_info: SandboxInfo,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
) -> AppConversationInfo:
app_conversation_info = (
@@ -90,9 +116,11 @@ async def valid_conversation(
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
)
# Sanity check - Make sure that the conversation and sandbox were created by the same user
if app_conversation_info.created_by_user_id != sandbox_info.created_by_user_id:
# Make sure that the conversation and sandbox were created by the same user
raise AuthError()
return app_conversation_info
@@ -139,15 +167,11 @@ async def on_conversation_update(
async def on_event(
events: list[Event],
conversation_id: UUID,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
app_conversation_info: AppConversationInfo = Depends(valid_conversation),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
event_service: EventService = event_service_dependency,
) -> Success:
"""Webhook callback for when event stream events occur."""
app_conversation_info = await valid_conversation(
conversation_id, sandbox_info, app_conversation_info_service
)
try:
# Save events...
await asyncio.gather(
@@ -13,7 +13,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:1.12.0-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.13.0-python'
class SandboxSpecService(ABC):
Generated
+24 -26
View File
@@ -6367,14 +6367,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.12.0"
version = "1.13.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.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
]
[package.dependencies]
@@ -6391,14 +6391,14 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
]
[package.dependencies]
@@ -6421,14 +6421,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
]
[package.dependencies]
@@ -11577,14 +11577,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
]
[package.extras]
@@ -13579,24 +13579,22 @@ files = [
[[package]]
name = "tornado"
version = "6.5.4"
version = "6.5.5"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
python-versions = ">=3.9"
groups = ["main", "runtime"]
files = [
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
]
[[package]]
@@ -14848,4 +14846,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "7319bfec87aed5ed2803ad7cb947f875e83fa62216b1662a87b9b84078dc03e4"
content-hash = "8988a1da93e30d92a44ff7690ad39ce34a164c3a7b249e0d63a270a505bd52a9"
+7 -7
View File
@@ -57,9 +57,9 @@ dependencies = [
"numpy",
"openai==2.8",
"openhands-aci==0.3.3",
"openhands-agent-server==1.12",
"openhands-sdk==1.12",
"openhands-tools==1.12",
"openhands-agent-server==1.13",
"openhands-sdk==1.13",
"openhands-tools==1.13",
"opentelemetry-api>=1.33.1",
"opentelemetry-exporter-otlp-proto-grpc>=1.33.1",
"pathspec>=0.12.1",
@@ -144,7 +144,7 @@ runtime = [
[tool.poetry]
name = "openhands-ai"
version = "1.4.0"
version = "1.5.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -249,9 +249,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
openhands-sdk = "1.12"
openhands-agent-server = "1.12"
openhands-tools = "1.12"
openhands-sdk = "1.13"
openhands-agent-server = "1.13"
openhands-tools = "1.13"
jwcrypto = ">=1.5.6"
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"
@@ -161,6 +161,113 @@ class TestFilesystemEventServiceSearchEvents:
assert hasattr(result, 'next_page_id')
assert len(result.items) == 3
@pytest.mark.asyncio
async def test_search_events_pagination_limits_results(
self, service: FilesystemEventService
):
"""Test that search_events respects the limit parameter for pagination."""
conversation_id = uuid4()
total_events = 10
page_limit = 3
# Create more events than the limit
for _ in range(total_events):
await service.save_event(conversation_id, create_token_event())
# First page should return only 'limit' events
result = await service.search_events(conversation_id, limit=page_limit)
assert len(result.items) == page_limit
assert result.next_page_id is not None
@pytest.mark.asyncio
async def test_search_events_pagination_iterates_all_events(
self, service: FilesystemEventService
):
"""Test that pagination correctly iterates through all events without duplicates.
This test verifies the fix for a bug where pagination was applied to 'paths'
instead of 'items', causing all events to be returned on every page.
"""
conversation_id = uuid4()
total_events = 10
page_limit = 3
# Create events and track their IDs
created_event_ids = set()
for _ in range(total_events):
event = create_token_event()
created_event_ids.add(event.id)
await service.save_event(conversation_id, event)
# Iterate through all pages and collect event IDs
collected_event_ids = set()
page_id = None
page_count = 0
while True:
result = await service.search_events(
conversation_id, page_id=page_id, limit=page_limit
)
page_count += 1
for item in result.items:
# Verify no duplicates - this would fail with the old buggy code
assert item.id not in collected_event_ids, (
f'Duplicate event {item.id} found on page {page_count}'
)
collected_event_ids.add(item.id)
if result.next_page_id is None:
break
page_id = result.next_page_id
# Verify we got all events exactly once
assert collected_event_ids == created_event_ids
assert len(collected_event_ids) == total_events
# With 10 events and limit of 3, we should have 4 pages (3+3+3+1)
expected_pages = (total_events + page_limit - 1) // page_limit
assert page_count == expected_pages
@pytest.mark.asyncio
async def test_search_events_pagination_with_filters(
self, service: FilesystemEventService
):
"""Test that pagination works correctly when combined with filters."""
conversation_id = uuid4()
# Create a mix of events
token_events = [create_token_event() for _ in range(5)]
pause_events = [create_pause_event() for _ in range(3)]
for event in token_events + pause_events:
await service.save_event(conversation_id, event)
# Search only for token events with pagination
page_limit = 2
collected_ids = set()
page_id = None
while True:
result = await service.search_events(
conversation_id,
kind__eq='TokenEvent',
page_id=page_id,
limit=page_limit,
)
for item in result.items:
assert item.kind == 'TokenEvent'
collected_ids.add(item.id)
if result.next_page_id is None:
break
page_id = result.next_page_id
# Should have found all 5 token events
assert len(collected_ids) == 5
class TestFilesystemEventServiceIntegration:
"""Integration tests for FilesystemEventService."""
+313 -37
View File
@@ -3,18 +3,65 @@
This module tests the webhook authentication and authorization logic.
"""
from unittest.mock import AsyncMock, MagicMock
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from fastapi import HTTPException, status
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from openhands.app_server.event_callback.webhook_router import (
router as webhook_router,
)
from openhands.app_server.event_callback.webhook_router import (
valid_conversation,
valid_sandbox,
)
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
from openhands.app_server.user.specifiy_user_context import ADMIN
from openhands.app_server.user.specifiy_user_context import (
USER_CONTEXT_ATTR,
SpecifyUserContext,
)
from openhands.server.types import AppMode
class MockRequestState:
"""A mock request state that tracks attribute assignments."""
def __init__(self):
self._state = {}
self._attributes = {}
def __setattr__(self, name, value):
if name.startswith('_'):
super().__setattr__(name, value)
else:
self._attributes[name] = value
def __getattr__(self, name):
if name in self._attributes:
return self._attributes[name]
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
def create_mock_request():
"""Create a mock FastAPI Request object with proper state."""
request = MagicMock()
request.state = MockRequestState()
return request
def create_sandbox_service_context_manager(sandbox_service):
"""Create an async context manager that yields the given sandbox service."""
@contextlib.asynccontextmanager
async def _context_manager(state, request=None):
yield sandbox_service
return _context_manager
class TestValidSandbox:
@@ -22,14 +69,15 @@ class TestValidSandbox:
@pytest.mark.asyncio
async def test_valid_sandbox_with_valid_api_key(self):
"""Test that valid API key returns sandbox info."""
"""Test that valid API key returns sandbox info and sets user_context."""
# Arrange
session_api_key = 'valid-api-key-123'
user_id = 'user-123'
expected_sandbox = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id='user-123',
created_by_user_id=user_id,
sandbox_spec_id='spec-123',
)
@@ -38,12 +86,17 @@ class TestValidSandbox:
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Assert
assert result == expected_sandbox
@@ -51,18 +104,136 @@ class TestValidSandbox:
session_api_key
)
# Verify user_context is set correctly on request.state
assert USER_CONTEXT_ATTR in mock_request.state._attributes
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
assert isinstance(user_context, SpecifyUserContext)
assert user_context.user_id == user_id
@pytest.mark.asyncio
async def test_valid_sandbox_sets_user_context_to_sandbox_owner(self):
"""Test that user_context is set to the sandbox owner's user ID."""
# Arrange
session_api_key = 'valid-api-key'
sandbox_owner_id = 'sandbox-owner-user-id'
expected_sandbox = SandboxInfo(
id='sandbox-456',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id=sandbox_owner_id,
sandbox_spec_id='spec-456',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Assert - user_context should be set to the sandbox owner
assert USER_CONTEXT_ATTR in mock_request.state._attributes
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
assert isinstance(user_context, SpecifyUserContext)
assert user_context.user_id == sandbox_owner_id
@pytest.mark.asyncio
async def test_valid_sandbox_no_user_context_when_no_user_id(self):
"""Test that user_context is not set when sandbox has no created_by_user_id."""
# Arrange
session_api_key = 'valid-api-key'
expected_sandbox = SandboxInfo(
id='sandbox-789',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id=None, # No user ID
sandbox_spec_id='spec-789',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Assert - sandbox is returned but user_context should NOT be set
assert result == expected_sandbox
# Verify user_context is NOT set on request.state
assert USER_CONTEXT_ATTR not in mock_request.state._attributes
@pytest.mark.asyncio
async def test_valid_sandbox_no_user_context_when_no_user_id_raises_401_in_saas_mode(
self,
):
"""Test that user_context is not set when sandbox has no created_by_user_id."""
# Arrange
session_api_key = 'valid-api-key'
expected_sandbox = SandboxInfo(
id='sandbox-789',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id=None, # No user ID
sandbox_spec_id='spec-789',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
with (
patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
),
patch(
'openhands.app_server.event_callback.webhook_router.app_mode',
AppMode.SAAS,
),
):
with pytest.raises(HTTPException) as excinfo:
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
assert excinfo.value.status_code == 401
@pytest.mark.asyncio
async def test_valid_sandbox_without_api_key_raises_401(self):
"""Test that missing API key raises 401 error."""
# Arrange
mock_sandbox_service = AsyncMock()
mock_request = create_mock_request()
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
request=mock_request,
session_api_key=None,
sandbox_service=mock_sandbox_service,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
@@ -78,13 +249,18 @@ class TestValidSandbox:
return_value=None
)
mock_request = create_mock_request()
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert 'Invalid session API key' in exc_info.value.detail
@@ -95,13 +271,13 @@ class TestValidSandbox:
# Arrange - empty string is falsy, so it gets rejected at the check
session_api_key = ''
mock_sandbox_service = AsyncMock()
mock_request = create_mock_request()
# Act & Assert - should raise 401 because empty string fails the truth check
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
request=mock_request,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
@@ -263,12 +439,17 @@ class TestWebhookAuthenticationIntegration:
return_value=conversation_info
)
mock_request = create_mock_request()
# Act - Call valid_sandbox first
sandbox_result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
sandbox_result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Then call valid_conversation
conversation_result = await valid_conversation(
@@ -291,13 +472,18 @@ class TestWebhookAuthenticationIntegration:
return_value=None
)
mock_request = create_mock_request()
# Act & Assert - Should fail at valid_sandbox
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
@@ -328,12 +514,17 @@ class TestWebhookAuthenticationIntegration:
return_value=different_user_info
)
mock_request = create_mock_request()
# Act - valid_sandbox succeeds
sandbox_result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
sandbox_result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# But valid_conversation fails
from openhands.app_server.errors import AuthError
@@ -344,3 +535,88 @@ class TestWebhookAuthenticationIntegration:
sandbox_info=sandbox_result,
app_conversation_info_service=mock_conversation_service,
)
class TestWebhookRouterHTTPIntegration:
"""Integration tests for webhook router HTTP layer.
These tests validate that FastAPI routing correctly extracts conversation_id
from the request body rather than requiring it as a query parameter.
"""
def test_conversation_update_endpoint_does_not_require_query_param(self):
"""Test that /webhooks/conversations endpoint accepts conversation_id in body only.
This test validates the fix for the regression where the endpoint incorrectly
required conversation_id as a query parameter due to using Depends(valid_conversation).
The endpoint should:
1. Accept POST requests without any query parameters
2. Extract conversation_id from the request body (conversation_info.id)
3. Return 401 (not 422) when auth fails, proving the request was parsed correctly
"""
# Create a minimal FastAPI app with just the webhook router
app = FastAPI()
app.include_router(webhook_router, prefix='/api/v1')
client = TestClient(app, raise_server_exceptions=False)
# Create a valid request body with conversation_id in it
conversation_id = str(uuid4())
request_body = {
'id': conversation_id,
'execution_status': 'running',
'agent': {
'llm': {
'model': 'gpt-4',
},
},
'stats': {
'usage_to_metrics': {},
},
}
# POST to /webhooks/conversations WITHOUT any query parameters
# If the old bug existed (conversation_id required as query param),
# FastAPI would return 422 Unprocessable Entity
response = client.post(
'/api/v1/webhooks/conversations',
json=request_body,
# No X-Session-API-Key header - should fail auth but NOT validation
)
# We expect 401 Unauthorized (missing session API key)
# NOT 422 Unprocessable Entity (which would indicate conversation_id
# was incorrectly required as a query parameter)
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
f'Expected 401 (auth failure), got {response.status_code}. '
f'If 422, the endpoint incorrectly requires conversation_id as query param. '
f'Response: {response.json()}'
)
assert response.json()['detail'] == 'X-Session-API-Key header is required'
def test_events_endpoint_still_requires_conversation_id_in_path(self):
"""Test that /webhooks/events/{conversation_id} correctly requires path param.
This ensures we didn't accidentally break the events endpoint which legitimately
requires conversation_id as a path parameter.
"""
# Create a minimal FastAPI app with just the webhook router
app = FastAPI()
app.include_router(webhook_router, prefix='/api/v1')
client = TestClient(app, raise_server_exceptions=False)
conversation_id = str(uuid4())
request_body = [] # Empty events list
# POST to /webhooks/events/{conversation_id} with path parameter
response = client.post(
f'/api/v1/webhooks/events/{conversation_id}',
json=request_body,
# No X-Session-API-Key header - should fail auth but NOT validation
)
# We expect 401 Unauthorized (missing session API key)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json()['detail'] == 'X-Session-API-Key header is required'
@@ -19,6 +19,7 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
SQLAppConversationInfoService,
)
from openhands.app_server.event_callback.webhook_router import on_conversation_update
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
from openhands.app_server.utils.sql_utils import Base
@@ -118,9 +119,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Saved conversation retains the parent_conversation_id
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -137,12 +135,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -175,9 +172,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Saved conversation has parent_conversation_id as None
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
conversation_id = mock_conversation_info.id
@@ -191,12 +185,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=None,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -228,9 +221,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- New conversation has parent_conversation_id as None
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
conversation_id = mock_conversation_info.id
@@ -242,12 +232,11 @@ class TestOnConversationUpdateParentConversationId:
created_by_user_id=sandbox_info.created_by_user_id,
)
# Mock valid_conversation to return stub (as it would for new conversation)
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=stub_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -280,9 +269,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- All metadata including parent_conversation_id is preserved
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -302,12 +288,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -349,9 +334,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Parent_conversation_id remains unchanged after all updates
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -366,9 +348,8 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return conversation with parent
# In real scenario, this would be retrieved from DB after first save
async def mock_valid_conv(*args, **kwargs):
# Act - Update multiple times, simulating what valid_conversation would return
for _ in range(3):
# After first save, get from DB with parent preserved
saved = await app_conversation_info_service.get_app_conversation_info(
conversation_id
@@ -376,21 +357,20 @@ class TestOnConversationUpdateParentConversationId:
if saved:
# Override created_by_user_id for auth check
saved.created_by_user_id = 'user_123'
return saved
return initial_conv
existing = saved
else:
existing = initial_conv
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
side_effect=mock_valid_conv,
):
# Act - Update multiple times
for _ in range(3):
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing,
):
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
app_conversation_info_service=app_conversation_info_service,
)
assert isinstance(result, Success)
assert isinstance(result, Success)
# Assert
saved_conv = await app_conversation_info_service.get_app_conversation_info(
@@ -417,9 +397,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Function returns early, no updates are made
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -441,12 +418,11 @@ class TestOnConversationUpdateParentConversationId:
# Set conversation to DELETING status
mock_conversation_info.execution_status = ConversationExecutionStatus.DELETING
# Mock valid_conversation (though it won't be called for DELETING status)
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -481,9 +457,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Parent_conversation_id is preserved and title is generated
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -498,12 +471,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -451,11 +451,9 @@ class TestOnEventStatsProcessing:
@pytest.mark.asyncio
async def test_on_event_processes_stats_events(self):
"""Test that on_event processes stats events."""
from unittest.mock import patch
from openhands.app_server.event_callback.webhook_router import on_event
from openhands.app_server.sandbox.sandbox_models import (
SandboxInfo,
SandboxStatus,
)
conversation_id = uuid4()
sandbox_id = 'sandbox_123'
@@ -482,15 +480,6 @@ class TestOnEventStatsProcessing:
events = [stats_event, other_event]
# Mock dependencies
mock_sandbox = SandboxInfo(
id=sandbox_id,
status=SandboxStatus.RUNNING,
session_api_key='test_key',
created_by_user_id='user_123',
sandbox_spec_id='spec_123',
)
mock_app_conversation_info = AppConversationInfo(
id=conversation_id,
sandbox_id=sandbox_id,
@@ -499,9 +488,6 @@ class TestOnEventStatsProcessing:
mock_event_service = AsyncMock()
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
mock_app_conversation_info
)
# Set up process_stats_event to call update_conversation_statistics
async def process_stats_event_side_effect(event, conversation_id):
@@ -519,44 +505,33 @@ class TestOnEventStatsProcessing:
process_stats_event_side_effect
)
with (
patch(
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
return_value=mock_sandbox,
),
patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=mock_app_conversation_info,
),
patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
) as mock_callbacks,
):
with patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
) as mock_callbacks:
# Call on_event directly with dependencies
await on_event(
events=events,
conversation_id=conversation_id,
sandbox_info=mock_sandbox,
app_conversation_info=mock_app_conversation_info,
app_conversation_info_service=mock_app_conversation_info_service,
event_service=mock_event_service,
)
# Verify events were saved
assert mock_event_service.save_event.call_count == 2
# Verify events were saved
assert mock_event_service.save_event.call_count == 2
# Verify stats event was processed
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
# Verify stats event was processed
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
# Verify callbacks were scheduled
mock_callbacks.assert_called_once()
# Verify callbacks were scheduled
mock_callbacks.assert_called_once()
@pytest.mark.asyncio
async def test_on_event_skips_non_stats_events(self):
"""Test that on_event skips non-stats events."""
from unittest.mock import patch
from openhands.app_server.event_callback.webhook_router import on_event
from openhands.app_server.sandbox.sandbox_models import (
SandboxInfo,
SandboxStatus,
)
from openhands.events.action.message import MessageAction
conversation_id = uuid4()
@@ -568,14 +543,6 @@ class TestOnEventStatsProcessing:
MessageAction(content='test'),
]
mock_sandbox = SandboxInfo(
id=sandbox_id,
status=SandboxStatus.RUNNING,
session_api_key='test_key',
created_by_user_id='user_123',
sandbox_spec_id='spec_123',
)
mock_app_conversation_info = AppConversationInfo(
id=conversation_id,
sandbox_id=sandbox_id,
@@ -584,30 +551,18 @@ class TestOnEventStatsProcessing:
mock_event_service = AsyncMock()
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
mock_app_conversation_info
)
with (
patch(
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
return_value=mock_sandbox,
),
patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=mock_app_conversation_info,
),
patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
),
with patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
):
# Call on_event directly with dependencies
await on_event(
events=events,
conversation_id=conversation_id,
sandbox_info=mock_sandbox,
app_conversation_info=mock_app_conversation_info,
app_conversation_info_service=mock_app_conversation_info_service,
event_service=mock_event_service,
)
# Verify stats update was NOT called
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
# Verify stats update was NOT called
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
Generated
+26 -28
View File
@@ -3642,7 +3642,7 @@ wheels = [
[[package]]
name = "openhands-agent-server"
version = "1.12.0"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
@@ -3656,9 +3656,9 @@ dependencies = [
{ name = "websockets" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/18/d76d977201ec93faf22d6cc979b5c9953a0b554bf3294cdb3186d48a5d5a/openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d", size = 72715, upload-time = "2026-03-05T19:22:23.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/d0/419756ad3368e7ab47c07111dfb4bf40073c110817914e09553b8e056fe8/openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a", size = 73594, upload-time = "2026-03-10T18:41:25.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/47/dc31d7ffd6f6687ce4cc0114e01cf1f7f13f9ba841cd47dac5a983e57fb9/openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad", size = 87800, upload-time = "2026-03-05T19:22:27.857Z" },
{ url = "https://files.pythonhosted.org/packages/fc/e1/77b9b3181e6cba89c601533757d148f911416ff968a4ea5fe0882d479ccf/openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29", size = 88607, upload-time = "2026-03-10T18:41:18.321Z" },
]
[[package]]
@@ -3826,9 +3826,9 @@ requires-dist = [
{ name = "numpy" },
{ name = "openai", specifier = "==2.8" },
{ name = "openhands-aci", specifier = "==0.3.3" },
{ name = "openhands-agent-server", specifier = "==1.12" },
{ name = "openhands-sdk", specifier = "==1.12" },
{ name = "openhands-tools", specifier = "==1.12" },
{ name = "openhands-agent-server", specifier = "==1.13" },
{ name = "openhands-sdk", specifier = "==1.13" },
{ name = "openhands-tools", specifier = "==1.13" },
{ name = "opentelemetry-api", specifier = ">=1.33.1" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" },
{ name = "pathspec", specifier = ">=0.12.1" },
@@ -3906,7 +3906,7 @@ test = [
[[package]]
name = "openhands-sdk"
version = "1.12.0"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "agent-client-protocol" },
@@ -3923,14 +3923,14 @@ dependencies = [
{ name = "tenacity" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/44/715dd4c43e1a4ba2c47ebd251240dd6aca0dd604cc1354932f0344f93b40/openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9", size = 323133, upload-time = "2026-03-05T19:22:26.623Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/5e35e99252f16c3e9b8eec843b7054ed7d3ad9fadcc0b40064ab3de55469/openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c", size = 330526, upload-time = "2026-03-10T18:41:19.513Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/2f/b7ba4f261d806aaab46f372d2049503ccedde373bb0648b88ebce58ebfe7/openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02", size = 411337, upload-time = "2026-03-05T19:22:29.369Z" },
{ url = "https://files.pythonhosted.org/packages/12/b1/31737964179a8e5a0ed1d0485082a703e2d4cd346701ab4a383ddf33eebb/openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185", size = 420504, upload-time = "2026-03-10T18:41:24.224Z" },
]
[[package]]
name = "openhands-tools"
version = "1.12.0"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bashlex" },
@@ -3943,9 +3943,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "tom-swe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/84/9552e75326c341707d36f7a86ba9a55a8fcb48bfd97e4d1ebe989260fdd8/openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c", size = 110293, upload-time = "2026-03-05T19:22:23.906Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/91/0af0f29dc0da57e7df13bd1653eff80d5c47b8311c6825568837d6ba2af7/openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d", size = 111922, upload-time = "2026-03-10T18:41:26.872Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/26/70031063c81bb1215f5a5d85c33c4e62e6a3d318dd8e3609e5ce68040faa/openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33", size = 150468, upload-time = "2026-03-05T19:22:24.974Z" },
{ url = "https://files.pythonhosted.org/packages/a2/e7/44d677fdd73f249c9bc8a76d2a32848ed96f54324b7d4b0589bb70f7d4e8/openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68", size = 152193, upload-time = "2026-03-10T18:41:20.563Z" },
]
[[package]]
@@ -7383,11 +7383,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
]
[[package]]
@@ -8528,21 +8528,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.4"
version = "6.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
{ url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
{ url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
{ url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
{ url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
{ url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
{ url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
{ url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
]
[[package]]