mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
V1 resolver: move PR/issue context into initial user message (#12983)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -231,6 +231,29 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_instructions=conversation_instructions,
|
||||
)
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
"""Build the initial user message for V1 resolver conversations.
|
||||
|
||||
For "issue opened" events (no specific comment body), we can simply
|
||||
concatenate the user prompt and the rendered issue context.
|
||||
|
||||
Subclasses that represent comment-driven events (issue comments, PR review
|
||||
comments, inline review comments) override this method to control ordering
|
||||
(e.g., context first, then the triggering comment, then previous comments).
|
||||
"""
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
|
||||
parts: list[str] = []
|
||||
if user_instructions.strip():
|
||||
parts.append(user_instructions.strip())
|
||||
if conversation_instructions.strip():
|
||||
parts.append(conversation_instructions.strip())
|
||||
|
||||
return '\n\n'.join(parts)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
@@ -240,13 +263,11 @@ class GithubIssue(ResolverViewInterface):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
initial_user_text = await self._get_v1_initial_user_message(jinja_env)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_instructions)]
|
||||
role='user', content=[TextContent(text=initial_user_text)]
|
||||
)
|
||||
|
||||
# Create the GitHub V1 callback processor
|
||||
@@ -258,7 +279,9 @@ class GithubIssue(ResolverViewInterface):
|
||||
# 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,
|
||||
# NOTE: Resolver instructions are intended to be lower priority than the
|
||||
# system prompt, so we inject them into the initial user message.
|
||||
system_message_suffix=None,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
@@ -329,6 +352,17 @@ class GithubIssueComment(GithubIssue):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
await self._load_resolver_context()
|
||||
template = jinja_env.get_template('issue_comment_initial_message.j2')
|
||||
return template.render(
|
||||
issue_number=self.issue_number,
|
||||
issue_title=self.title,
|
||||
issue_body=self.description,
|
||||
issue_comment=self.comment_body,
|
||||
previous_comments=self.previous_comments,
|
||||
).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GithubPRComment(GithubIssueComment):
|
||||
@@ -355,6 +389,18 @@ class GithubPRComment(GithubIssueComment):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
await self._load_resolver_context()
|
||||
template = jinja_env.get_template('pr_update_initial_message.j2')
|
||||
return template.render(
|
||||
pr_number=self.issue_number,
|
||||
branch_name=self.branch_name,
|
||||
pr_title=self.title,
|
||||
pr_body=self.description,
|
||||
pr_comment=self.comment_body,
|
||||
comments=self.previous_comments,
|
||||
).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GithubInlinePRComment(GithubPRComment):
|
||||
@@ -401,6 +447,20 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
await self._load_resolver_context()
|
||||
template = jinja_env.get_template('pr_update_initial_message.j2')
|
||||
return template.render(
|
||||
pr_number=self.issue_number,
|
||||
branch_name=self.branch_name,
|
||||
pr_title=self.title,
|
||||
pr_body=self.description,
|
||||
file_location=self.file_location,
|
||||
line_number=self.line_number,
|
||||
pr_comment=self.comment_body,
|
||||
comments=self.previous_comments,
|
||||
).strip()
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from integrations.github.github_v1_callback_processor import (
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import (
|
||||
GithubInlinePRComment,
|
||||
GithubIssueComment,
|
||||
GithubPRComment,
|
||||
)
|
||||
from integrations.types import UserData
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartTaskStatus,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jinja_env() -> Environment:
|
||||
repo_root = Path(__file__).resolve().parents[5]
|
||||
return Environment(
|
||||
loader=FileSystemLoader(
|
||||
str(repo_root / 'openhands/integrations/templates/resolver/github')
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_app_conversation_service_ctx(fake_service):
|
||||
yield fake_service
|
||||
|
||||
|
||||
class _FakeAppConversationService:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
|
||||
async def start_app_conversation(self, request):
|
||||
self.requests.append(request)
|
||||
yield MagicMock(status=AppConversationStartTaskStatus.READY, detail=None)
|
||||
|
||||
|
||||
def _build_conversation_metadata() -> ConversationMetadata:
|
||||
return ConversationMetadata(
|
||||
conversation_id=str(uuid4()),
|
||||
selected_repository='test-owner/test-repo',
|
||||
)
|
||||
|
||||
|
||||
def _build_user_data() -> UserData:
|
||||
return UserData(user_id=1, username='test-user', keycloak_user_id='kc-user')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGithubViewV1InitialUserMessage:
|
||||
@patch('integrations.github.github_view.get_app_conversation_service')
|
||||
async def test_issue_comment_v1_injects_context_into_initial_user_message(
|
||||
self,
|
||||
mock_get_app_conversation_service,
|
||||
jinja_env,
|
||||
):
|
||||
view = GithubIssueComment(
|
||||
installation_id=123,
|
||||
issue_number=42,
|
||||
full_repo_name='test-owner/test-repo',
|
||||
is_public_repo=False,
|
||||
user_info=_build_user_data(),
|
||||
raw_payload=MagicMock(),
|
||||
conversation_id='conv',
|
||||
uuid=None,
|
||||
should_extract=False,
|
||||
send_summary_instruction=False,
|
||||
title='ignored',
|
||||
description='ignored',
|
||||
previous_comments=[],
|
||||
v1_enabled=True,
|
||||
comment_body='please fix this',
|
||||
comment_id=999,
|
||||
)
|
||||
|
||||
async def _load_context():
|
||||
view.title = 'Issue title'
|
||||
view.description = 'Issue body'
|
||||
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
_fake_app_conversation_service_ctx(fake_service)
|
||||
)
|
||||
|
||||
await view._create_v1_conversation(
|
||||
jinja_env=jinja_env,
|
||||
saas_user_auth=MagicMock(),
|
||||
conversation_metadata=_build_conversation_metadata(),
|
||||
)
|
||||
|
||||
assert len(fake_service.requests) == 1
|
||||
req = fake_service.requests[0]
|
||||
assert req.system_message_suffix is None
|
||||
|
||||
text = req.initial_message.content[0].text
|
||||
assert 'Issue title' in text
|
||||
assert 'Issue body' in text
|
||||
assert 'please fix this' in text
|
||||
assert 'old comment 1' in text
|
||||
|
||||
@patch('integrations.github.github_view.get_app_conversation_service')
|
||||
async def test_pr_comment_v1_injects_context_and_comment_into_initial_user_message(
|
||||
self,
|
||||
mock_get_app_conversation_service,
|
||||
jinja_env,
|
||||
):
|
||||
view = GithubPRComment(
|
||||
installation_id=123,
|
||||
issue_number=7,
|
||||
full_repo_name='test-owner/test-repo',
|
||||
is_public_repo=False,
|
||||
user_info=_build_user_data(),
|
||||
raw_payload=MagicMock(),
|
||||
conversation_id='conv',
|
||||
uuid=None,
|
||||
should_extract=False,
|
||||
send_summary_instruction=False,
|
||||
title='ignored',
|
||||
description='ignored',
|
||||
previous_comments=[],
|
||||
v1_enabled=True,
|
||||
comment_body='nit: rename variable',
|
||||
comment_id=1001,
|
||||
branch_name='feature-branch',
|
||||
)
|
||||
|
||||
async def _load_context():
|
||||
view.title = 'PR title'
|
||||
view.description = 'PR body'
|
||||
view.previous_comments = [
|
||||
MagicMock(author='bob', created_at='2026-01-01', body='old thread')
|
||||
]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
_fake_app_conversation_service_ctx(fake_service)
|
||||
)
|
||||
|
||||
await view._create_v1_conversation(
|
||||
jinja_env=jinja_env,
|
||||
saas_user_auth=MagicMock(),
|
||||
conversation_metadata=_build_conversation_metadata(),
|
||||
)
|
||||
|
||||
assert len(fake_service.requests) == 1
|
||||
req = fake_service.requests[0]
|
||||
assert req.system_message_suffix is None
|
||||
|
||||
text = req.initial_message.content[0].text
|
||||
assert 'feature-branch' in text
|
||||
assert 'PR title' in text
|
||||
assert 'PR body' in text
|
||||
assert 'nit: rename variable' in text
|
||||
assert 'old thread' in text
|
||||
|
||||
@patch('integrations.github.github_view.get_app_conversation_service')
|
||||
async def test_inline_pr_comment_v1_includes_file_context(
|
||||
self, mock_get_service, jinja_env
|
||||
):
|
||||
view = GithubInlinePRComment(
|
||||
installation_id=123,
|
||||
issue_number=7,
|
||||
full_repo_name='test-owner/test-repo',
|
||||
is_public_repo=False,
|
||||
user_info=_build_user_data(),
|
||||
raw_payload=MagicMock(),
|
||||
conversation_id='conv',
|
||||
uuid=None,
|
||||
should_extract=False,
|
||||
send_summary_instruction=False,
|
||||
title='ignored',
|
||||
description='ignored',
|
||||
previous_comments=[],
|
||||
v1_enabled=True,
|
||||
comment_body='please add a null check',
|
||||
comment_id=1002,
|
||||
branch_name='feature-branch',
|
||||
file_location='src/app.py',
|
||||
line_number=123,
|
||||
comment_node_id='node',
|
||||
)
|
||||
|
||||
async def _load_context():
|
||||
view.title = 'PR title'
|
||||
view.description = 'PR body'
|
||||
view.previous_comments = []
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)
|
||||
|
||||
await view._create_v1_conversation(
|
||||
jinja_env=jinja_env,
|
||||
saas_user_auth=MagicMock(),
|
||||
conversation_metadata=_build_conversation_metadata(),
|
||||
)
|
||||
|
||||
req = fake_service.requests[0]
|
||||
assert req.system_message_suffix is None
|
||||
text = req.initial_message.content[0].text
|
||||
assert 'src/app.py' in text
|
||||
assert '123' in text
|
||||
assert 'please add a null check' in text
|
||||
@@ -0,0 +1,31 @@
|
||||
You are requested to fix issue #{{ issue_number }}: "{{ issue_title }}" in a repository.
|
||||
A comment on the issue has been addressed to you.
|
||||
|
||||
# Issue Body
|
||||
{{ issue_body }}
|
||||
|
||||
# Comment
|
||||
{{ issue_comment }}
|
||||
|
||||
{% if previous_comments %}
|
||||
# Previous Comments
|
||||
For reference, here are the previous comments on the issue:
|
||||
|
||||
{% for comment in previous_comments %}
|
||||
- @{{ comment.author }} said:
|
||||
{{ comment.body }}
|
||||
{% if not loop.last %}
|
||||
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
# Guidelines
|
||||
|
||||
1. Review the task carefully.
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
# Final Checklist
|
||||
Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements.
|
||||
@@ -0,0 +1,41 @@
|
||||
You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}: "{{ pr_title }}".
|
||||
A comment on the PR has been addressed to you.
|
||||
|
||||
# PR Description
|
||||
{{ pr_body }}
|
||||
|
||||
{% if file_location %}
|
||||
# Comment location
|
||||
The comment is in the file `{{ file_location }}` on line #{{ line_number }}.
|
||||
{% endif %}
|
||||
|
||||
# Comment
|
||||
{{ pr_comment }}
|
||||
|
||||
{% if comments %}
|
||||
# Previous Comments
|
||||
You may find these other comments relevant:
|
||||
{% for comment in comments %}
|
||||
- @{{ comment.author }} said at {{ comment.created_at }}:
|
||||
{{ comment.body }}
|
||||
{% if not loop.last %}
|
||||
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
## Understand the PR Context
|
||||
Use the $GITHUB_TOKEN and GitHub API to:
|
||||
1. Retrieve the diff against the base branch (typically main) to understand the changes
|
||||
2. Fetch the PR body and the linked issue for context
|
||||
|
||||
## Process the Comment
|
||||
If it's a question, answer it.
|
||||
|
||||
If it requests a code update:
|
||||
1. Modify the code accordingly in the current branch
|
||||
2. Commit your changes with a clear commit message
|
||||
3. Verify if the branch is on a fork, and make sure the remote is correct
|
||||
4. Push the changes to GitHub to update the PR.
|
||||
Reference in New Issue
Block a user