V1 resolver: move PR/issue context into initial user message (#12983)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Engel Nyst
2026-03-04 16:21:08 +01:00
committed by GitHub
parent baae3780e5
commit f01c8dd955
4 changed files with 355 additions and 5 deletions

View File

@@ -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 (

View File

@@ -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

View File

@@ -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.

View File

@@ -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.