V1: Support v1 conversations in github resolver (#11773)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra 2025-11-26 13:11:05 -05:00 committed by GitHub
parent 014884333d
commit 9906a1d49a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 8123 additions and 7183 deletions

View File

@ -292,18 +292,26 @@ class GithubManager(Manager):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
send_summary_instruction=True,
)
from openhands.server.shared import ConversationStoreImpl, config
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Github] Registered callback processor for conversation {conversation_id}'
conversation_store = await ConversationStoreImpl.get_instance(
config, github_view.user_info.keycloak_user_id
)
metadata = await conversation_store.get_metadata(conversation_id)
if metadata.conversation_version != 'v1':
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Github] Registered callback processor for conversation {conversation_id}'
)
# Send message with conversation link
conversation_link = CONVERSATION_URL.format(conversation_id)

View File

@ -1,4 +1,4 @@
from uuid import uuid4
from uuid import UUID, uuid4
from github import Github, GithubIntegration
from github.Issue import Issue
@ -26,10 +26,22 @@ from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.sdk.conversation.secret_source import SecretSource
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
@ -43,6 +55,49 @@ from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
class GithubUserContext(UserContext):
"""User context for GitHub integration that provides user info without web request."""
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
self.keycloak_user_id = keycloak_user_id
self.git_provider_tokens = git_provider_tokens
self.settings_store = SaasSettingsStore(
user_id=self.keycloak_user_id,
session_maker=session_maker,
config=get_config(),
)
self.secrets_store = SaasSecretsStore(
self.keycloak_user_id, session_maker, get_config()
)
async def get_user_id(self) -> str | None:
return self.keycloak_user_id
async def get_user_info(self) -> UserInfo:
user_settings = await self.settings_store.load()
return UserInfo(
id=self.keycloak_user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
return self.git_provider_tokens.get(ProviderType.GITHUB)
return None
async def get_secrets(self) -> dict[str, SecretSource]:
# Return empty dict for now - GitHub integration handles secrets separately
user_secrets = await self.secrets_store.load()
return dict(user_secrets.custom_secrets) if user_secrets else {}
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
"""Get the user's proactive conversation setting.
@ -76,6 +131,35 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
return settings.enable_proactive_conversation_starters
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
"""
# If no user ID is provided, we can't check user settings
if not user_id:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.v1_enabled is None:
return False
return settings.v1_enabled
# =================================================
# SECTION: Github view types
# =================================================
@ -159,6 +243,31 @@ class GithubIssue(ResolverViewInterface):
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
if v1_enabled:
try:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
return
except Exception as e:
logger.warning(f'Error checking V1 settings, falling back to V0: {e}')
# Use existing V0 conversation service
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
@ -177,6 +286,77 @@ class GithubIssue(ResolverViewInterface):
conversation_instructions=conversation_instructions,
)
async def _create_v1_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=user_instructions)]
)
# Create the GitHub V1 callback processor
github_callback_processor = self._create_github_v1_callback_processor()
# Get the app conversation service and start the conversation
injector_state = InjectorState()
# Create the V1 conversation start request with the callback processor
start_request = AppConversationStartRequest(
conversation_id=UUID(conversation_metadata.conversation_id),
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.full_repo_name,
git_provider=ProviderType.GITHUB,
title=f'GitHub Issue #{self.issue_number}: {self.title}',
trigger=ConversationTrigger.RESOLVER,
processors=[
github_callback_processor
], # Pass the callback processor directly
)
# Set up the GitHub user context for the V1 system
github_user_context = GithubUserContext(
keycloak_user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
# Create and return the GitHub V1 callback processor
return GithubV1CallbackProcessor(
github_view_data={
'issue_number': self.issue_number,
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
send_summary_instruction=self.send_summary_instruction,
)
@dataclass
class GithubIssueComment(GithubIssue):
@ -292,6 +472,24 @@ class GithubInlinePRComment(GithubPRComment):
return user_instructions, conversation_instructions
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
# Create and return the GitHub V1 callback processor
return GithubV1CallbackProcessor(
github_view_data={
'issue_number': self.issue_number,
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
'comment_id': self.comment_id,
},
inline_pr_comment=True,
send_summary_instruction=self.send_summary_instruction,
)
@dataclass
class GithubFailingAction:

View File

@ -0,0 +1,31 @@
"""Add v1_enabled column to user_settings
Revision ID: 083
Revises: 082
Create Date: 2025-11-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '083'
down_revision: Union[str, None] = '082'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add v1_enabled column to user_settings table."""
op.add_column(
'user_settings',
sa.Column('v1_enabled', sa.Boolean(), nullable=False, default=False),
)
def downgrade() -> None:
"""Remove v1_enabled column from user_settings table."""
op.drop_column('user_settings', 'v1_enabled')

14
enterprise/poetry.lock generated
View File

@ -3055,8 +3055,6 @@ files = [
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
@ -3066,8 +3064,6 @@ files = [
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
@ -3077,8 +3073,6 @@ files = [
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
@ -3088,8 +3082,6 @@ files = [
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
@ -3097,8 +3089,6 @@ files = [
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
@ -3108,8 +3098,6 @@ files = [
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
@ -5855,7 +5843,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5591+c191ab6d2"
version = "0.62.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"

View File

@ -38,3 +38,4 @@ class UserSettings(Base): # type: ignore
email_verified = Column(Boolean, nullable=True)
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
v1_enabled = Column(Boolean, nullable=False, default=False)

View File

@ -1,7 +1,9 @@
from unittest import TestCase, mock
from unittest.mock import MagicMock, patch
from integrations.github.github_view import GithubFactory, get_oh_labels
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
from integrations.models import Message, SourceType
from integrations.types import UserData
class TestGithubLabels(TestCase):
@ -75,3 +77,128 @@ class TestGithubCommentCaseInsensitivity(TestCase):
self.assertTrue(GithubFactory.is_issue_comment(message_lower))
self.assertTrue(GithubFactory.is_issue_comment(message_upper))
self.assertTrue(GithubFactory.is_issue_comment(message_mixed))
class TestGithubV1ConversationRouting(TestCase):
"""Test V1 conversation routing logic in GitHub integration."""
def setUp(self):
"""Set up test fixtures."""
# Create a proper UserData instance instead of MagicMock
user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
# Create a mock raw_payload
raw_payload = Message(
source=SourceType.GITHUB,
message={
'payload': {
'action': 'opened',
'issue': {'number': 123},
}
},
)
self.github_issue = GithubIssue(
user_info=user_data,
full_repo_name='test/repo',
issue_number=123,
installation_id=456,
conversation_id='test-conversation-id',
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=raw_payload,
uuid='test-uuid',
title='Test Issue',
description='Test issue description',
previous_comments=[],
)
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
async def test_create_new_conversation_routes_to_v0_when_disabled(
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
):
"""Test that conversation creation routes to V0 when v1_enabled is False."""
# Mock v1_enabled as False
mock_get_v1_setting.return_value = False
mock_create_v0.return_value = None
mock_create_v1.return_value = None
# Mock parameters
jinja_env = MagicMock()
git_provider_tokens = MagicMock()
conversation_metadata = MagicMock()
# Call the method
await self.github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
# Verify V0 was called and V1 was not
mock_create_v0.assert_called_once_with(
jinja_env, git_provider_tokens, conversation_metadata
)
mock_create_v1.assert_not_called()
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
async def test_create_new_conversation_routes_to_v1_when_enabled(
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
):
"""Test that conversation creation routes to V1 when v1_enabled is True."""
# Mock v1_enabled as True
mock_get_v1_setting.return_value = True
mock_create_v0.return_value = None
mock_create_v1.return_value = None
# Mock parameters
jinja_env = MagicMock()
git_provider_tokens = MagicMock()
conversation_metadata = MagicMock()
# Call the method
await self.github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
# Verify V1 was called and V0 was not
mock_create_v1.assert_called_once_with(
jinja_env, git_provider_tokens, conversation_metadata
)
mock_create_v0.assert_not_called()
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
async def test_create_new_conversation_fallback_on_v1_setting_error(
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
):
"""Test that conversation creation falls back to V0 when _create_v1_conversation fails."""
# Mock v1_enabled as True so V1 is attempted
mock_get_v1_setting.return_value = True
# Mock _create_v1_conversation to raise an exception
mock_create_v1.side_effect = Exception('V1 conversation creation failed')
mock_create_v0.return_value = None
# Mock parameters
jinja_env = MagicMock()
git_provider_tokens = MagicMock()
conversation_metadata = MagicMock()
# Call the method
await self.github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
# Verify V1 was attempted first, then V0 was called as fallback
mock_create_v1.assert_called_once_with(
jinja_env, git_provider_tokens, conversation_metadata
)
mock_create_v0.assert_called_once_with(
jinja_env, git_provider_tokens, conversation_metadata
)

View File

@ -4,6 +4,8 @@
* Using CDN approach for better TypeScript compatibility
*/
import EventLogger from "./event-logger";
export interface ReoIdentity {
username: string;
type: "github" | "email";
@ -41,7 +43,7 @@ class ReoService {
this.initialized = true;
}
} catch (error) {
console.error("Failed to initialize Reo.dev tracking:", error);
EventLogger.error(`Failed to initialize Reo.dev tracking: ${error}`);
}
}
@ -78,7 +80,7 @@ class ReoService {
*/
identify(identity: ReoIdentity): void {
if (!this.initialized) {
console.warn("Reo.dev not initialized. Call init() first.");
EventLogger.warning("Reo.dev not initialized. Call init() first.");
return;
}
@ -87,7 +89,7 @@ class ReoService {
window.Reo.identify(identity);
}
} catch (error) {
console.error("Failed to identify user in Reo.dev:", error);
EventLogger.error(`Failed to identify user in Reo.dev: ${error}`);
}
}

View File

@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from uuid import uuid4
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
@ -97,7 +97,9 @@ class AppConversationStartRequest(BaseModel):
"""
sandbox_id: str | None = Field(default=None)
conversation_id: UUID | None = Field(default=None)
initial_message: SendMessageRequest | None = None
system_message_suffix: str | None = None
processors: list[EventCallbackProcessor] | None = Field(default=None)
llm_model: str | None = None

View File

@ -68,7 +68,7 @@ from openhands.app_server.utils.docker_utils import (
)
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import ProviderType
from openhands.sdk import LocalWorkspace
from openhands.sdk import AgentContext, LocalWorkspace
from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret
from openhands.sdk.llm import LLM
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
@ -230,10 +230,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
await self._build_start_conversation_request_for_user(
sandbox,
request.initial_message,
request.system_message_suffix,
request.git_provider,
sandbox_spec.working_dir,
request.agent_type,
request.llm_model,
request.conversation_id,
remote_workspace=remote_workspace,
selected_repository=request.selected_repository,
)
@ -279,22 +281,24 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
)
# Setup default processors
processors = request.processors
if processors is None:
processors = [SetTitleCallbackProcessor()]
processors = request.processors or []
# Always ensure SetTitleCallbackProcessor is included
has_set_title_processor = any(
isinstance(processor, SetTitleCallbackProcessor)
for processor in processors
)
if not has_set_title_processor:
processors.append(SetTitleCallbackProcessor())
# Save processors
await asyncio.gather(
*[
self.event_callback_service.save_event_callback(
EventCallback(
conversation_id=info.id,
processor=processor,
)
for processor in processors:
await self.event_callback_service.save_event_callback(
EventCallback(
conversation_id=info.id,
processor=processor,
)
for processor in processors
]
)
)
# Update the start task
task.status = AppConversationStartTaskStatus.READY
@ -519,10 +523,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
self,
sandbox: SandboxInfo,
initial_message: SendMessageRequest | None,
system_message_suffix: str | None,
git_provider: ProviderType | None,
working_dir: str,
agent_type: AgentType = AgentType.DEFAULT,
llm_model: str | None = None,
conversation_id: UUID | None = None,
remote_workspace: AsyncRemoteWorkspace | None = None,
selected_repository: str | None = None,
) -> StartConversationRequest:
@ -578,7 +584,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
else:
agent = get_default_agent(llm=llm)
conversation_id = uuid4()
agent_context = AgentContext(system_message_suffix=system_message_suffix)
agent = agent.model_copy(update={'agent_context': agent_context})
conversation_id = conversation_id or uuid4()
agent = ExperimentManagerImpl.run_agent_variant_tests__v1(
user.id, conversation_id, agent
)

View File

@ -9,6 +9,8 @@ from fastapi import Depends, Request
from pydantic import Field
from sqlalchemy.ext.asyncio import AsyncSession
# Import the event_callback module to ensure all processors are registered
import openhands.app_server.event_callback # noqa: F401
from openhands.agent_server.env_parser import from_env
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,

View File

@ -0,0 +1,21 @@
"""Event callback system for OpenHands.
This module provides the event callback system that allows processors to be
registered and executed when specific events occur during conversations.
All callback processors must be imported here to ensure they are registered
with the discriminated union system used by Pydantic for validation.
"""
# Import base classes and processors without circular dependencies
from .event_callback_models import EventCallbackProcessor, LoggingCallbackProcessor
from .github_v1_callback_processor import GithubV1CallbackProcessor
# Note: SetTitleCallbackProcessor is not imported here to avoid circular imports
# It will be registered when imported elsewhere in the application
__all__ = [
'EventCallbackProcessor',
'LoggingCallbackProcessor',
'GithubV1CallbackProcessor',
]

View File

@ -0,0 +1,296 @@
import logging
import os
from typing import Any
from uuid import UUID
import httpx
from github import Github, GithubIntegration
from pydantic import Field
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
get_conversation_url,
get_prompt_template,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
_logger = logging.getLogger(__name__)
class GithubV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for GitHub V1 integrations."""
github_view_data: dict[str, Any] = Field(default_factory=dict)
should_request_summary: bool = Field(default=True)
should_extract: bool = Field(default=True)
inline_pr_comment: bool = Field(default=False)
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitHub V1 integration."""
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
return None
_logger.info('[GitHub V1] Callback agent state was %s', event)
_logger.info(
'[GitHub V1] Should request summary: %s', self.should_request_summary
)
if not self.should_request_summary:
return None
self.should_request_summary = False
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_github(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception('[GitHub V1] Error processing callback: %s', e)
# Only try to post error to GitHub if we have basic requirements
try:
# Check if we have installation ID and credentials before posting
if (
self.github_view_data.get('installation_id')
and os.getenv('GITHUB_APP_CLIENT_ID')
and os.getenv('GITHUB_APP_PRIVATE_KEY')
):
await self._post_summary_to_github(
f'OpenHands encountered an error: **{str(e)}**.\n\n'
f'[See the conversation]({get_conversation_url().format(conversation_id)})'
'for more information.'
)
except Exception as post_error:
_logger.warning(
'[GitHub V1] Failed to post error message to GitHub: %s', post_error
)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
# -------------------------------------------------------------------------
# GitHub helpers
# -------------------------------------------------------------------------
def _get_installation_access_token(self) -> str:
installation_id = self.github_view_data.get('installation_id')
if not installation_id:
raise ValueError(
f'Missing installation ID for GitHub payload: {self.github_view_data}'
)
github_app_client_id = os.getenv('GITHUB_APP_CLIENT_ID', '').strip()
github_app_private_key = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace(
'\\n', '\n'
)
if not github_app_client_id or not github_app_private_key:
raise ValueError('GitHub App credentials are not configured')
github_integration = GithubIntegration(
github_app_client_id,
github_app_private_key,
)
token_data = github_integration.get_access_token(installation_id)
return token_data.token
async def _post_summary_to_github(self, summary: str) -> None:
"""Post a summary comment to the configured GitHub issue."""
installation_token = self._get_installation_access_token()
if not installation_token:
raise RuntimeError('Missing GitHub credentials')
full_repo_name = self.github_view_data['full_repo_name']
issue_number = self.github_view_data['issue_number']
if self.inline_pr_comment:
with Github(installation_token) as github_client:
repo = github_client.get_repo(full_repo_name)
pr = repo.get_pull(issue_number)
pr.create_review_comment_reply(
comment_id=self.github_view_data.get('comment_id', ''), body=summary
)
return
with Github(installation_token) as github_client:
repo = github_client.get_repo(full_repo_name)
issue = repo.get_issue(number=issue_number)
issue.create_comment(summary)
# -------------------------------------------------------------------------
# Agent / sandbox helpers
# -------------------------------------------------------------------------
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f'{agent_server_url.rstrip("/")}'
f'/api/conversations/{conversation_id}/ask_agent'
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception: # noqa: BLE001
pass
_logger.error(
'[GitHub V1] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
exc_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.error(
'[GitHub V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
except httpx.RequestError as e:
error_detail = f'Request error to {url}: {str(e)}'
_logger.error(
'[GitHub V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
# -------------------------------------------------------------------------
# Summary orchestration
# -------------------------------------------------------------------------
async def _request_summary(self, conversation_id: UUID) -> str:
"""
Ask the agent to produce a summary of its work and return the agent response.
NOTE: This method now returns a string (the agent server's response text)
and raises exceptions on errors. The wrapping into EventCallbackResult
is handled by __call__.
"""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert sandbox.session_api_key is not None, (
f'No session API key for sandbox: {sandbox.id}'
)
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_prompt_template('summary_prompt.j2')
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)

View File

@ -209,6 +209,10 @@ class SQLEventCallbackService(EventCallbackService):
for callback in callbacks
]
)
# Persist any new changes callbacks may have made to itself
for callback in callbacks:
await self.save_event_callback(callback)
await self.db_session.commit()
async def execute_callback(

View File

@ -0,0 +1,81 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
SandboxInfo,
SandboxStatus,
)
from openhands.app_server.utils.docker_utils import (
replace_localhost_hostname_for_docker,
)
if TYPE_CHECKING:
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
def get_conversation_url() -> str:
from openhands.app_server.config import get_global_config
web_url = get_global_config().web_url
conversation_prefix = 'conversations/{}'
conversation_url = f'{web_url}/{conversation_prefix}'
return conversation_url
def ensure_conversation_found(
app_conversation_info: AppConversationInfo | None, conversation_id: UUID
) -> AppConversationInfo:
"""Ensure conversation info exists, otherwise raise a clear error."""
if not app_conversation_info:
raise RuntimeError(f'Conversation not found: {conversation_id}')
return app_conversation_info
def ensure_running_sandbox(sandbox: SandboxInfo | None, sandbox_id: str) -> SandboxInfo:
"""Ensure sandbox exists, is running, and has a session API key."""
if not sandbox:
raise RuntimeError(f'Sandbox not found: {sandbox_id}')
if sandbox.status != SandboxStatus.RUNNING:
raise RuntimeError(f'Sandbox not running: {sandbox_id}')
if not sandbox.session_api_key:
raise RuntimeError(f'No session API key for sandbox: {sandbox.id}')
return sandbox
def get_agent_server_url_from_sandbox(sandbox: SandboxInfo) -> str:
"""Return the agent server URL from sandbox exposed URLs."""
exposed_urls = sandbox.exposed_urls
if not exposed_urls:
raise RuntimeError(f'No exposed URLs configured for sandbox {sandbox.id!r}')
try:
agent_server_url = next(
exposed_url.url
for exposed_url in exposed_urls
if exposed_url.name == AGENT_SERVER
)
except StopIteration:
raise RuntimeError(
f'No {AGENT_SERVER!r} URL found for sandbox {sandbox.id!r}'
) from None
return replace_localhost_hostname_for_docker(agent_server_url)
def get_prompt_template(template_name: str) -> str:
from jinja2 import Environment, FileSystemLoader
jinja_env = Environment(
loader=FileSystemLoader('openhands/integrations/templates/resolver/')
)
summary_instruction_template = jinja_env.get_template(template_name)
summary_instruction = summary_instruction_template.render()
return summary_instruction

View File

@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:27f0ba6-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:5f62cee-python'
class SandboxSpecService(ABC):

View File

@ -19,9 +19,11 @@ class DockerRuntimeBuilder(RuntimeBuilder):
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').replace('-', '.')
components = version_info.get('Components', [])
self.is_podman = bool(components) and components[0].get('Name', '').startswith(
'Podman'
components = version_info.get('Components')
self.is_podman = (
components is not None
and len(components) > 0
and components[0].get('Name', '').startswith('Podman')
)
if (
tuple(map(int, server_version.split('.')[:2])) < (18, 9)
@ -80,9 +82,11 @@ class DockerRuntimeBuilder(RuntimeBuilder):
self.docker_client = docker.from_env()
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').split('+')[0].replace('-', '.')
components = version_info.get('Components', [])
self.is_podman = bool(components) and components[0].get('Name', '').startswith(
'Podman'
components = version_info.get('Components')
self.is_podman = (
components is not None
and len(components) > 0
and components[0].get('Name', '').startswith('Podman')
)
if tuple(map(int, server_version.split('.'))) < (18, 9) and not self.is_podman:
raise AgentRuntimeBuildError(

View File

@ -48,6 +48,7 @@ class Settings(BaseModel):
email_verified: bool | None = None
git_user_name: str | None = None
git_user_email: str | None = None
v1_enabled: bool = False
model_config = ConfigDict(
validate_assignment=True,

13632
poetry.lock generated

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,771 @@
"""
Tests for the GithubV1CallbackProcessor.
Covers:
- Event filtering
- Successful summary + GitHub posting
- Inline PR comments
- Error conditions (missing IDs/credentials, conversation/sandbox issues)
- Agent server HTTP/timeout errors
- Low-level helper methods
"""
import os
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
import pytest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.app_server.event_callback.event_callback_models import EventCallback
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
from openhands.app_server.sandbox.sandbox_models import (
ExposedUrl,
SandboxInfo,
SandboxStatus,
)
from openhands.events.action.message import MessageAction
from openhands.sdk.event import ConversationStateUpdateEvent
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def github_callback_processor():
return GithubV1CallbackProcessor(
github_view_data={
'installation_id': 12345,
'full_repo_name': 'test-owner/test-repo',
'issue_number': 42,
},
should_request_summary=True,
should_extract=True,
inline_pr_comment=False,
)
@pytest.fixture
def github_callback_processor_inline():
return GithubV1CallbackProcessor(
github_view_data={
'installation_id': 12345,
'full_repo_name': 'test-owner/test-repo',
'issue_number': 42,
'comment_id': 'comment_123',
},
should_request_summary=True,
should_extract=True,
inline_pr_comment=True,
)
@pytest.fixture
def conversation_state_update_event():
return ConversationStateUpdateEvent(key='execution_status', value='finished')
@pytest.fixture
def wrong_event():
return MessageAction(content='Hello world')
@pytest.fixture
def wrong_state_event():
return ConversationStateUpdateEvent(key='execution_status', value='running')
@pytest.fixture
def event_callback():
return EventCallback(
id=uuid4(),
conversation_id=uuid4(),
processor=GithubV1CallbackProcessor(),
event_kind='ConversationStateUpdateEvent',
)
@pytest.fixture
def mock_app_conversation_info():
return AppConversationInfo(
conversation_id=uuid4(),
sandbox_id='sandbox_123',
title='Test Conversation',
created_by_user_id='test_user_123',
)
@pytest.fixture
def mock_sandbox_info():
return SandboxInfo(
id='sandbox_123',
status=SandboxStatus.RUNNING,
session_api_key='test_api_key',
created_by_user_id='test_user_123',
sandbox_spec_id='spec_123',
exposed_urls=[
ExposedUrl(name='AGENT_SERVER', url='http://localhost:8000', port=8000),
],
)
# ---------------------------------------------------------------------------
# Helper for common service mocks
# ---------------------------------------------------------------------------
async def _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
app_conversation_info,
sandbox_info,
agent_response_text='Test summary from agent',
):
# app_conversation_info_service
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
app_conversation_info
)
mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
mock_app_conversation_info_service
)
# sandbox_service
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox.return_value = sandbox_info
mock_get_sandbox_service.return_value.__aenter__.return_value = mock_sandbox_service
# httpx_client
mock_httpx_client = AsyncMock()
mock_response = MagicMock()
mock_response.json.return_value = {'response': agent_response_text}
mock_response.raise_for_status.return_value = None
mock_httpx_client.post.return_value = mock_response
mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
return mock_httpx_client
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestGithubV1CallbackProcessor:
async def test_call_with_wrong_event_type(
self, github_callback_processor, wrong_event, event_callback
):
result = await github_callback_processor(
conversation_id=uuid4(),
callback=event_callback,
event=wrong_event,
)
assert result is None
async def test_call_with_wrong_state_event(
self, github_callback_processor, wrong_state_event, event_callback
):
result = await github_callback_processor(
conversation_id=uuid4(),
callback=event_callback,
event=wrong_state_event,
)
assert result is None
async def test_call_should_request_summary_false(
self, github_callback_processor, conversation_state_update_event, event_callback
):
github_callback_processor.should_request_summary = False
result = await github_callback_processor(
conversation_id=uuid4(),
callback=event_callback,
event=conversation_state_update_event,
)
assert result is None
# ------------------------------------------------------------------ #
# Successful paths
# ------------------------------------------------------------------ #
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
async def test_successful_callback_execution(
self,
mock_github,
mock_github_integration,
mock_get_prompt_template,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
github_callback_processor,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
conversation_id = uuid4()
# Common service mocks
mock_httpx_client = await _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_app_conversation_info,
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
# GitHub integration
mock_token_data = MagicMock()
mock_token_data.token = 'test_access_token'
mock_integration_instance = MagicMock()
mock_integration_instance.get_access_token.return_value = mock_token_data
mock_github_integration.return_value = mock_integration_instance
# GitHub API
mock_github_client = MagicMock()
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_repo.get_issue.return_value = mock_issue
mock_github_client.get_repo.return_value = mock_repo
mock_github.return_value.__enter__.return_value = mock_github_client
result = await github_callback_processor(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.SUCCESS
assert result.event_callback_id == event_callback.id
assert result.event_id == conversation_state_update_event.id
assert result.conversation_id == conversation_id
assert result.detail == 'Test summary from agent'
assert github_callback_processor.should_request_summary is False
mock_github_integration.assert_called_once_with(
'test_client_id', 'test_private_key'
)
mock_integration_instance.get_access_token.assert_called_once_with(12345)
mock_github.assert_called_once_with('test_access_token')
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=42)
mock_issue.create_comment.assert_called_once_with('Test summary from agent')
mock_httpx_client.post.assert_called_once()
url_arg, kwargs = mock_httpx_client.post.call_args
url = url_arg[0] if url_arg else kwargs['url']
assert 'ask_agent' in url
assert kwargs['headers']['X-Session-API-Key'] == 'test_api_key'
assert kwargs['json']['question'] == 'Please provide a summary'
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
async def test_successful_inline_pr_comment(
self,
mock_github,
mock_github_integration,
mock_get_prompt_template,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
github_callback_processor_inline,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
conversation_id = uuid4()
await _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_app_conversation_info,
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
mock_token_data = MagicMock()
mock_token_data.token = 'test_access_token'
mock_integration_instance = MagicMock()
mock_integration_instance.get_access_token.return_value = mock_token_data
mock_github_integration.return_value = mock_integration_instance
mock_github_client = MagicMock()
mock_repo = MagicMock()
mock_pr = MagicMock()
mock_repo.get_pull.return_value = mock_pr
mock_github_client.get_repo.return_value = mock_repo
mock_github.return_value.__enter__.return_value = mock_github_client
result = await github_callback_processor_inline(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.SUCCESS
mock_repo.get_pull.assert_called_once_with(42)
mock_pr.create_review_comment_reply.assert_called_once_with(
comment_id='comment_123', body='Test summary from agent'
)
# ------------------------------------------------------------------ #
# Error paths
# ------------------------------------------------------------------ #
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
async def test_missing_installation_id(
self,
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
processor = GithubV1CallbackProcessor(
github_view_data={}, should_request_summary=True
)
conversation_id = uuid4()
await _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_app_conversation_info,
mock_sandbox_info,
)
result = await processor(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert 'Missing installation ID' in result.detail
@patch.dict(os.environ, {}, clear=True)
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
async def test_missing_github_credentials(
self,
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
github_callback_processor,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
conversation_id = uuid4()
await _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_app_conversation_info,
mock_sandbox_info,
)
result = await github_callback_processor(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert 'GitHub App credentials are not configured' in result.detail
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
async def test_sandbox_not_running(
self,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
github_callback_processor,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
):
conversation_id = uuid4()
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
mock_app_conversation_info
)
mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
mock_app_conversation_info_service
)
non_running_sandbox = SandboxInfo(
id='sandbox_123',
status=SandboxStatus.PAUSED,
session_api_key='test_api_key',
created_by_user_id='test_user_123',
sandbox_spec_id='spec_123',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox.return_value = non_running_sandbox
mock_get_sandbox_service.return_value.__aenter__.return_value = (
mock_sandbox_service
)
result = await github_callback_processor(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert 'Sandbox not running' in result.detail
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
async def test_agent_server_http_error(
self,
mock_get_prompt_template,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
github_callback_processor,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
conversation_id = uuid4()
# Set up happy path except httpx
await _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_app_conversation_info,
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = 'Internal Server Error'
mock_response.headers = {}
mock_error = httpx.HTTPStatusError(
'HTTP 500 error', request=MagicMock(), response=mock_response
)
mock_httpx_client.post.side_effect = mock_error
result = await github_callback_processor(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert 'Failed to send message to agent server' in result.detail
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
async def test_agent_server_timeout(
self,
mock_get_prompt_template,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
github_callback_processor,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
conversation_id = uuid4()
await _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_app_conversation_info,
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
mock_httpx_client.post.side_effect = httpx.TimeoutException('Request timeout')
result = await github_callback_processor(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert 'Request timeout after 30 seconds' in result.detail
# ------------------------------------------------------------------ #
# Low-level helper tests
# ------------------------------------------------------------------ #
def test_get_installation_access_token_missing_id(self):
processor = GithubV1CallbackProcessor(github_view_data={})
with pytest.raises(ValueError, match='Missing installation ID'):
processor._get_installation_access_token()
@patch.dict(os.environ, {}, clear=True)
def test_get_installation_access_token_missing_credentials(
self, github_callback_processor
):
with pytest.raises(
ValueError, match='GitHub App credentials are not configured'
):
github_callback_processor._get_installation_access_token()
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key\\nwith_newlines',
},
)
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
)
def test_get_installation_access_token_success(
self, mock_github_integration, github_callback_processor
):
mock_token_data = MagicMock()
mock_token_data.token = 'test_access_token'
mock_integration_instance = MagicMock()
mock_integration_instance.get_access_token.return_value = mock_token_data
mock_github_integration.return_value = mock_integration_instance
token = github_callback_processor._get_installation_access_token()
assert token == 'test_access_token'
mock_github_integration.assert_called_once_with(
'test_client_id', 'test_private_key\nwith_newlines'
)
mock_integration_instance.get_access_token.assert_called_once_with(12345)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
async def test_post_summary_to_github_issue_comment(
self, mock_github, github_callback_processor
):
mock_github_client = MagicMock()
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_repo.get_issue.return_value = mock_issue
mock_github_client.get_repo.return_value = mock_repo
mock_github.return_value.__enter__.return_value = mock_github_client
with patch.object(
github_callback_processor,
'_get_installation_access_token',
return_value='test_token',
):
await github_callback_processor._post_summary_to_github('Test summary')
mock_github.assert_called_once_with('test_token')
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=42)
mock_issue.create_comment.assert_called_once_with('Test summary')
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
async def test_post_summary_to_github_pr_comment(
self, mock_github, github_callback_processor_inline
):
mock_github_client = MagicMock()
mock_repo = MagicMock()
mock_pr = MagicMock()
mock_repo.get_pull.return_value = mock_pr
mock_github_client.get_repo.return_value = mock_repo
mock_github.return_value.__enter__.return_value = mock_github_client
with patch.object(
github_callback_processor_inline,
'_get_installation_access_token',
return_value='test_token',
):
await github_callback_processor_inline._post_summary_to_github(
'Test summary'
)
mock_github.assert_called_once_with('test_token')
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_pull.assert_called_once_with(42)
mock_pr.create_review_comment_reply.assert_called_once_with(
comment_id='comment_123', body='Test summary'
)
async def test_post_summary_to_github_missing_token(
self, github_callback_processor
):
with patch.object(
github_callback_processor, '_get_installation_access_token', return_value=''
):
with pytest.raises(RuntimeError, match='Missing GitHub credentials'):
await github_callback_processor._post_summary_to_github('Test summary')
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
'WEB_HOST': 'test.example.com',
},
)
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
async def test_exception_handling_posts_error_to_github(
self,
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
github_callback_processor,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
conversation_id = uuid4()
# happy-ish path, except httpx error
mock_httpx_client = await _setup_happy_path_services(
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_app_conversation_info,
mock_sandbox_info,
)
mock_httpx_client.post.side_effect = Exception('Simulated agent server error')
with (
patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
) as mock_github_integration,
patch(
'openhands.app_server.event_callback.github_v1_callback_processor.Github'
) as mock_github,
):
mock_integration = MagicMock()
mock_github_integration.return_value = mock_integration
mock_integration.get_access_token.return_value.token = 'test_token'
mock_gh = MagicMock()
mock_github.return_value.__enter__.return_value = mock_gh
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_repo.get_issue.return_value = mock_issue
mock_gh.get_repo.return_value = mock_repo
result = await github_callback_processor(
conversation_id=conversation_id,
callback=event_callback,
event=conversation_state_update_event,
)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert 'Simulated agent server error' in result.detail
mock_issue.create_comment.assert_called_once()
call_args = mock_issue.create_comment.call_args
error_comment = call_args[1].get('body') or call_args[0][0]
assert (
'OpenHands encountered an error: **Simulated agent server error**'
in error_comment
)
assert f'conversations/{conversation_id}' in error_comment
assert 'for more information.' in error_comment

View File

@ -126,11 +126,8 @@ class TestExperimentManagerIntegration:
self,
):
"""
Use the real LiveStatusAppConversationService to build a StartConversationRequest,
and verify ExperimentManagerImpl.run_agent_variant_tests__v1:
- is called exactly once with the (user_id, generated conversation_id, agent)
- returns the *same* agent instance (no copy/mutation)
- does not tweak agent fields (LLM, system prompt, etc.)
Test that ExperimentManagerImpl.run_agent_variant_tests__v1 is called with correct parameters
and returns the same agent instance (no copy/mutation) when building a StartConversationRequest.
"""
# --- Arrange: fixed UUID to assert call parameters deterministically
fixed_conversation_id = UUID('00000000-0000-0000-0000-000000000001')
@ -143,6 +140,7 @@ class TestExperimentManagerIntegration:
mock_agent = Mock(spec=Agent)
mock_agent.llm = mock_llm
mock_agent.system_prompt_filename = 'default_system_prompt.j2'
mock_agent.model_copy = Mock(return_value=mock_agent)
# Minimal, real-ish user context used by the service
class DummyUserContext:
@ -210,16 +208,32 @@ class TestExperimentManagerIntegration:
'openhands.app_server.app_conversation.live_status_app_conversation_service.uuid4',
return_value=fixed_conversation_id,
),
patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
) as mock_experiment_manager,
):
# Configure the experiment manager mock to return the same agent
mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
mock_agent
)
# --- Act: build the start request
start_req = await service._build_start_conversation_request_for_user(
sandbox=sandbox,
initial_message=None,
system_message_suffix=None, # No additional system message suffix
git_provider=None, # Keep secrets path simple
working_dir='/tmp/project', # Arbitrary path
)
# The agent in the StartConversationRequest is the *same* object we provided
# --- Assert: verify experiment manager was called with correct parameters
mock_experiment_manager.run_agent_variant_tests__v1.assert_called_once_with(
'test_user_123', # user_id
fixed_conversation_id, # conversation_id
mock_agent, # agent (after model_copy with agent_context)
)
# The agent in the StartConversationRequest is the *same* object returned by experiment manager
assert start_req.agent is mock_agent
# No tweaks to agent fields by the experiment manager (noop)