feat: hide conversations after PR closure or merge (microagent management) (#10600)

This commit is contained in:
Hiep Le
2025-08-27 16:32:04 +07:00
committed by GitHub
parent 50391ecdf3
commit 57aa7d5c12
11 changed files with 1078 additions and 77 deletions

View File

@@ -17,7 +17,7 @@ const mockUseUserProviders = vi.fn();
const mockUseGitRepositories = vi.fn();
const mockUseConfig = vi.fn();
const mockUseRepositoryMicroagents = vi.fn();
const mockUseSearchConversations = vi.fn();
const mockUseMicroagentManagementConversations = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
@@ -35,8 +35,9 @@ vi.mock("#/hooks/query/use-repository-microagents", () => ({
useRepositoryMicroagents: () => mockUseRepositoryMicroagents(),
}));
vi.mock("#/hooks/query/use-search-conversations", () => ({
useSearchConversations: () => mockUseSearchConversations(),
vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
useMicroagentManagementConversations: () =>
mockUseMicroagentManagementConversations(),
}));
describe("MicroagentManagement", () => {
@@ -212,7 +213,7 @@ describe("MicroagentManagement", () => {
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: mockConversations,
isLoading: false,
isError: false,
@@ -859,8 +860,8 @@ describe("MicroagentManagement", () => {
});
// Search conversations functionality tests
describe("Search conversations functionality", () => {
it("should call searchConversations API when repository is expanded", async () => {
describe("Microagent management conversations functionality", () => {
it("should call useMicroagentManagementConversations API when repository is expanded", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
@@ -876,7 +877,7 @@ describe("MicroagentManagement", () => {
// Wait for both microagents and conversations to be fetched
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
});
@@ -896,7 +897,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -921,7 +922,7 @@ describe("MicroagentManagement", () => {
isLoading: true,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
@@ -958,7 +959,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that loading spinner is not displayed
@@ -983,7 +984,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that microagent file paths are displayed for microagents
@@ -1013,7 +1014,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -1033,7 +1034,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed
@@ -1050,7 +1051,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [...mockConversations],
isLoading: false,
isError: false,
@@ -1070,7 +1071,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that conversations are displayed
@@ -1093,7 +1094,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -1113,7 +1114,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -1131,7 +1132,7 @@ describe("MicroagentManagement", () => {
it("should handle error when fetching conversations", async () => {
const user = userEvent.setup();
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
@@ -1150,7 +1151,7 @@ describe("MicroagentManagement", () => {
// Wait for the error to be handled
await waitFor(() => {
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed (since conversations failed)
@@ -1195,7 +1196,7 @@ describe("MicroagentManagement", () => {
expect(learnThisRepo).toBeInTheDocument();
});
it("should call searchConversations with correct parameters", async () => {
it("should call useMicroagentManagementConversations with correct parameters", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
@@ -1208,9 +1209,9 @@ describe("MicroagentManagement", () => {
const repoAccordion = screen.getByTestId("repository-name-tooltip");
await user.click(repoAccordion);
// Wait for searchConversations to be called
// Wait for useMicroagentManagementConversations to be called
await waitFor(() => {
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
});
@@ -1230,7 +1231,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that conversations display correct information
@@ -1257,7 +1258,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to be called for first repo
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that both microagents and conversations are displayed
@@ -2391,7 +2392,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -2411,7 +2412,7 @@ describe("MicroagentManagement", () => {
// Wait for microagents and conversations to be fetched
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Verify the learn this repo trigger is displayed when no microagents exist
@@ -2436,7 +2437,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -2491,7 +2492,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -2508,7 +2509,7 @@ describe("MicroagentManagement", () => {
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Should NOT show the learn this repo trigger when microagents exist

View File

@@ -726,6 +726,27 @@ class OpenHands {
);
return data;
}
static async getMicroagentManagementConversations(
selectedRepository: string,
pageId?: string,
limit: number = 100,
): Promise<Conversation[]> {
const params: Record<string, string | number> = {
limit,
selected_repository: selectedRepository,
};
if (pageId) {
params.page_id = pageId;
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/microagent-management/conversations",
{ params },
);
return data.results;
}
}
export default OpenHands;

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations";
import { GitRepository } from "#/types/git";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
@@ -42,9 +42,9 @@ export function MicroagentManagementRepoMicroagents({
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(
} = useMicroagentManagementConversations(
repositoryName,
"microagent_management",
undefined,
1000,
true,
);

View File

@@ -0,0 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useMicroagentManagementConversations = (
selectedRepository: string,
pageId?: string,
limit: number = 100,
cacheDisabled: boolean = false,
) =>
useQuery({
queryKey: [
"conversations",
"microagent-management",
pageId,
limit,
selectedRepository,
],
queryFn: () =>
OpenHands.getMicroagentManagementConversations(
selectedRepository,
pageId,
limit,
),
enabled: !!selectedRepository,
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
});

View File

@@ -593,6 +593,21 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
# Return the URL to the pull request
return data.get('links', {}).get('html', {}).get('href', '')
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The pull request number
Returns:
Raw Bitbucket API response for the pull request
"""
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
pr_data, _ = await self._make_request(url)
return pr_data
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
@@ -628,6 +643,40 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
# Parse the content to extract triggers from frontmatter
return self._parse_microagent_content(response, file_path)
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a Bitbucket pull request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (OPEN), False if closed/merged
"""
try:
pr_details = await self.get_pr_details(repository, pr_number)
# Bitbucket API response structure
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
if 'state' in pr_details:
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
return pr_details['state'] == 'OPEN'
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True
bitbucket_service_cls = os.environ.get(
'OPENHANDS_BITBUCKET_SERVICE_CLS',

View File

@@ -676,6 +676,21 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
# Return the HTML URL of the created PR
return response['html_url']
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The pull request number
Returns:
Raw GitHub API response for the pull request
"""
url = f'{self.BASE_URL}/repos/{repository}/pulls/{pr_number}'
pr_data, _ = await self._make_request(url)
return pr_data
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
@@ -699,6 +714,42 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
# Parse the content to extract triggers from frontmatter
return self._parse_microagent_content(file_content, file_path)
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a GitHub PR is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (open), False if closed/merged
"""
try:
pr_details = await self.get_pr_details(repository, pr_number)
# GitHub API response structure
# https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
if 'state' in pr_details:
return pr_details['state'] == 'open'
elif 'merged' in pr_details and 'closed_at' in pr_details:
# Check if PR is merged or closed
return not (pr_details['merged'] or pr_details['closed_at'])
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine GitHub PR status for {repository}#{pr_number}. '
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine GitHub PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True
async def get_issue_or_pr_comments(
self, repository: str, issue_number: int, max_comments: int = 10
) -> list[Comment]:

View File

@@ -5,6 +5,7 @@ from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
BaseGitService,
Branch,
@@ -626,6 +627,22 @@ class GitLabService(BaseGitService, GitService):
return response['web_url']
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific merge request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
Raw GitLab API response for the merge request
"""
project_id = self._extract_project_id(repository)
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
mr_data, _ = await self._make_request(url)
return mr_data
def _extract_project_id(self, repository: str) -> str:
"""Extract project_id from repository name for GitLab API calls.
@@ -749,6 +766,42 @@ class GitLabService(BaseGitService, GitService):
return all_comments
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a GitLab merge request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
True if MR is active (opened), False if closed/merged
"""
try:
mr_details = await self.get_pr_details(repository, pr_number)
# GitLab API response structure
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
if 'state' in mr_details:
return mr_details['state'] == 'opened'
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
# Check if MR is merged or closed
return not (mr_details['merged_at'] or mr_details['closed_at'])
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the MR status, include the conversation to be safe
return True
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',

View File

@@ -613,3 +613,30 @@ class ProviderHandler:
remote_url = f'https://{domain}/{repo_name}.git'
return remote_url
async def is_pr_open(
self, repository: str, pr_number: int, git_provider: ProviderType
) -> bool:
"""Check if a PR is still active (not closed/merged).
This method checks the PR status using the provider's service method.
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
git_provider: The Git provider type for this repository
Returns:
True if PR is active (open), False if closed/merged, True if can't determine
"""
try:
service = self._get_service(git_provider)
return await service.is_pr_open(repository, pr_number)
except Exception as e:
logger.warning(
f'Could not determine PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True

View File

@@ -520,3 +520,27 @@ class GitService(Protocol):
MicroagentContentResponse with parsed content and triggers
"""
...
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request/merge request
Args:
repository: Repository name in format specific to the provider
pr_number: The pull request/merge request number
Returns:
Raw API response from the git provider
"""
...
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a PR is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (open), False if closed/merged
"""
...

View File

@@ -79,6 +79,85 @@ from openhands.utils.conversation_summary import get_default_conversation_title
app = APIRouter(prefix='/api', dependencies=get_dependencies())
def _filter_conversations_by_age(
conversations: list[ConversationMetadata], max_age_seconds: int
) -> list:
"""Filter conversations by age, removing those older than max_age_seconds.
Args:
conversations: List of conversations to filter
max_age_seconds: Maximum age in seconds for conversations to be included
Returns:
List of conversations that meet the age criteria
"""
now = datetime.now(timezone.utc)
filtered_results = []
for conversation in conversations:
# Skip conversations without created_at or older than max_age
if not hasattr(conversation, 'created_at'):
continue
age_seconds = (
now - conversation.created_at.replace(tzinfo=timezone.utc)
).total_seconds()
if age_seconds > max_age_seconds:
continue
filtered_results.append(conversation)
return filtered_results
async def _build_conversation_result_set(
filtered_conversations: list, next_page_id: str | None
) -> ConversationInfoResultSet:
"""Build a ConversationInfoResultSet from filtered conversations.
This function handles the common logic of getting conversation IDs, connections,
agent loop info, and building the final result set.
Args:
filtered_conversations: List of filtered conversations
next_page_id: Next page ID for pagination
Returns:
ConversationInfoResultSet with the processed conversations
"""
conversation_ids = set(
conversation.conversation_id for conversation in filtered_conversations
)
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
filter_to_sids=conversation_ids
)
agent_loop_info = await conversation_manager.get_agent_loop_info(
filter_to_sids=conversation_ids
)
agent_loop_info_by_conversation_id = {
info.conversation_id: info for info in agent_loop_info
}
result = ConversationInfoResultSet(
results=await wait_all(
_get_conversation_info(
conversation=conversation,
num_connections=sum(
1
for conversation_id in connection_ids_to_conversation_ids.values()
if conversation_id == conversation.conversation_id
),
agent_loop_info=agent_loop_info_by_conversation_id.get(
conversation.conversation_id
),
)
for conversation in filtered_conversations
),
next_page_id=next_page_id,
)
return result
class InitSessionRequest(BaseModel):
repository: str | None = None
git_provider: ProviderType | None = None
@@ -220,22 +299,14 @@ async def search_conversations(
) -> ConversationInfoResultSet:
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
# Apply filters at API level
filtered_results = []
now = datetime.now(timezone.utc)
max_age = config.conversation_max_age_seconds
for conversation in conversation_metadata_result_set.results:
# Skip conversations without created_at or older than max_age
if not hasattr(conversation, 'created_at'):
continue
age_seconds = (
now - conversation.created_at.replace(tzinfo=timezone.utc)
).total_seconds()
if age_seconds > max_age:
continue
# Apply age filter first using common function
filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results, config.conversation_max_age_seconds
)
# Apply additional filters
final_filtered_results = []
for conversation in filtered_results:
# Apply repository filter
if (
selected_repository is not None
@@ -250,38 +321,11 @@ async def search_conversations(
):
continue
filtered_results.append(conversation)
final_filtered_results.append(conversation)
conversation_ids = set(
conversation.conversation_id for conversation in filtered_results
return await _build_conversation_result_set(
final_filtered_results, conversation_metadata_result_set.next_page_id
)
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
filter_to_sids=conversation_ids
)
agent_loop_info = await conversation_manager.get_agent_loop_info(
filter_to_sids=conversation_ids
)
agent_loop_info_by_conversation_id = {
info.conversation_id: info for info in agent_loop_info
}
result = ConversationInfoResultSet(
results=await wait_all(
_get_conversation_info(
conversation=conversation,
num_connections=sum(
1
for conversation_id in connection_ids_to_conversation_ids.values()
if conversation_id == conversation.conversation_id
),
agent_loop_info=agent_loop_info_by_conversation_id.get(
conversation.conversation_id
),
)
for conversation in filtered_results
),
next_page_id=conversation_metadata_result_set.next_page_id,
)
return result
@app.get('/conversations/{conversation_id}')
@@ -725,3 +769,65 @@ def add_experiment_config_for_conversation(
return True
return False
@app.get('/microagent-management/conversations')
async def get_microagent_management_conversations(
selected_repository: str,
page_id: str | None = None,
limit: int = 20,
conversation_store: ConversationStore = Depends(get_conversation_store),
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
) -> ConversationInfoResultSet:
"""Get conversations for the microagent management page with pagination support.
This endpoint returns conversations with conversation_trigger = 'microagent_management'
and only includes conversations with active PRs. Pagination is supported.
Args:
page_id: Optional page ID for pagination
limit: Maximum number of results per page (default: 20)
selected_repository: Optional repository filter to limit results to a specific repository
conversation_store: Conversation store dependency
provider_tokens: Provider tokens for checking PR status
"""
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
# Apply age filter first using common function
filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results, config.conversation_max_age_seconds
)
# Check if the last PR is active (not closed/merged)
provider_handler = ProviderHandler(provider_tokens)
# Apply additional filters
final_filtered_results = []
for conversation in filtered_results:
# Only include microagent_management conversations
if conversation.trigger != ConversationTrigger.MICROAGENT_MANAGEMENT:
continue
# Apply repository filter if specified
if conversation.selected_repository != selected_repository:
continue
if (
conversation.pr_number
and len(conversation.pr_number) > 0
and conversation.selected_repository
and conversation.git_provider
and not await provider_handler.is_pr_open(
conversation.selected_repository,
conversation.pr_number[-1], # Get the last PR number
conversation.git_provider,
)
):
# Skip this conversation if the PR is closed/merged
continue
final_filtered_results.append(conversation)
return await _build_conversation_result_set(
final_filtered_results, conversation_metadata_result_set.next_page_id
)

View File

@@ -0,0 +1,642 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhands.integrations.provider import ProviderHandler
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
from openhands.server.routes.manage_conversations import (
get_microagent_management_conversations,
)
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_success():
"""Test successful retrieval of microagent management conversations."""
# Mock data
page_id = 'test_page_123'
limit = 10
selected_repository = 'owner/repo'
# Create mock conversations
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id='next_page_456')
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id='next_page_456'
)
# Mock config
mock_config.conversation_max_age_seconds = 86400 # 24 hours
# Call the function with correct parameter order
result = await get_microagent_management_conversations(
selected_repository=selected_repository,
page_id=page_id,
limit=limit,
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify the result
assert isinstance(result, ConversationInfoResultSet)
assert result.next_page_id == 'next_page_456'
# Verify conversation store was called correctly
mock_conversation_store.search.assert_called_once_with(page_id, limit)
# Verify provider handler was created with correct tokens
mock_provider_handler.is_pr_open.assert_called()
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_no_results():
"""Test when no conversations match the criteria."""
# Mock conversation store with empty results
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=[], next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function with required selected_repository parameter
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify the result
assert isinstance(result, ConversationInfoResultSet)
assert result.next_page_id is None
assert len(result.results) == 0
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_filter_by_repository():
"""Test filtering conversations by selected repository."""
# Create mock conversations with different repositories
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo1',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo2',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only repo1 should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function with repository filter
result = await get_microagent_management_conversations(
selected_repository='owner/repo1',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only conversations from the specified repository are returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
assert result.results[0].selected_repository == 'owner/repo1'
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_filter_by_trigger():
"""Test that only microagent_management conversations are returned."""
# Create mock conversations with different triggers
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.GUI, # Different trigger
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only microagent_management should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only microagent_management conversations are returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
assert result.results[0].trigger == ConversationTrigger.MICROAGENT_MANAGEMENT
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_filter_inactive_pr():
"""Test filtering out conversations with inactive PRs."""
# Create mock conversations
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler with one active and one inactive PR
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(side_effect=[True, False])
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only active PR should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only conversations with active PRs are returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
# Verify provider handler was called for both PRs
assert mock_provider_handler.is_pr_open.call_count == 2
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_no_pr_number():
"""Test conversations without PR numbers are included."""
# Create mock conversations without PR numbers
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=[], # No PR number
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=mock_conversations, next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify conversation without PR number is included
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
# Verify provider handler was not called (no PR to check)
mock_provider_handler.is_pr_open.assert_not_called()
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_no_repository():
"""Test conversations without selected repository are filtered out for PR checks."""
# Create mock conversations without repository
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository=None, # No repository
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - conversation should be filtered out due to repository mismatch
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify conversation without repository is filtered out
assert len(result.results) == 0
# Verify provider handler was not called (no repository for PR check)
mock_provider_handler.is_pr_open.assert_not_called()
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_age_filter():
"""Test that conversations are filtered by age."""
# Create mock conversations with different ages
now = datetime.now(timezone.utc)
old_conversation = ConversationMetadata(
conversation_id='conv_old',
user_id='user_1',
title='Old Conversation',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=now.replace(year=now.year - 1), # Very old
last_updated_at=now.replace(year=now.year - 1),
)
recent_conversation = ConversationMetadata(
conversation_id='conv_recent',
user_id='user_2',
title='Recent Conversation',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=now, # Recent
last_updated_at=now,
)
mock_conversations = [old_conversation, recent_conversation]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only recent conversation should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[recent_conversation], next_page_id=None
)
# Mock config with short max age
mock_config.conversation_max_age_seconds = 3600 # 1 hour
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only recent conversation is returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_recent'
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_pagination():
"""Test pagination functionality."""
# Mock conversation store with pagination
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=[], next_page_id='next_page_789')
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id='next_page_789'
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function with pagination parameters
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
page_id='test_page',
limit=5,
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify pagination parameters were passed correctly
mock_conversation_store.search.assert_called_once_with('test_page', 5)
assert result.next_page_id == 'next_page_789'
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_default_parameters():
"""Test default parameter values."""
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=[], next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function without parameters (selected_repository is required)
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify default values were used
mock_conversation_store.search.assert_called_once_with(None, 20)
assert isinstance(result, ConversationInfoResultSet)