mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
V1: Support v1 conversations in github resolver (#11773)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
014884333d
commit
9906a1d49a
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
14
enterprise/poetry.lock
generated
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
21
openhands/app_server/event_callback/__init__.py
Normal file
21
openhands/app_server/event_callback/__init__.py
Normal 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',
|
||||
]
|
||||
@ -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,
|
||||
)
|
||||
@ -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(
|
||||
|
||||
81
openhands/app_server/event_callback/util.py
Normal file
81
openhands/app_server/event_callback/util.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
13632
poetry.lock
generated
File diff suppressed because one or more lines are too long
771
tests/unit/app_server/test_github_v1_callback_processor.py
Normal file
771
tests/unit/app_server/test_github_v1_callback_processor.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user