feat(frontend): Integrate with the API to add a new microagent. (#9821)

This commit is contained in:
Hiep Le
2025-07-22 23:57:05 +07:00
committed by GitHub
parent 58ea7b5248
commit 38ffc85470
24 changed files with 1786 additions and 107 deletions

View File

@@ -1,4 +1,4 @@
import { screen, waitFor, within } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientConfig } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
@@ -9,6 +9,7 @@ import MicroagentManagement from "#/routes/microagent-management";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
@@ -121,6 +122,37 @@ describe("MicroagentManagement", () => {
},
];
const mockConversations: Conversation[] = [
{
conversation_id: "conv-1",
title: "Test Conversation 1",
selected_repository: "user/repo2/.openhands",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING",
runtime_status: null,
trigger: "microagent_management",
url: null,
session_api_key: null,
},
{
conversation_id: "conv-2",
title: "Test Conversation 2",
selected_repository: "user/repo2/.openhands",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STOPPED",
runtime_status: null,
trigger: "microagent_management",
url: null,
session_api_key: null,
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
@@ -132,6 +164,14 @@ describe("MicroagentManagement", () => {
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
...mockMicroagents,
]);
// Setup default mock for searchConversations
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
...mockConversations,
]);
// Mock branches to always return a 'main' branch for the modal
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "abc123", protected: false },
]);
});
it("should render the microagent management page", async () => {
@@ -311,11 +351,9 @@ describe("MicroagentManagement", () => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
});
// Check that the learn this repo component is displayed
const learnThisRepo = screen.getByText(
"MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
);
expect(learnThisRepo).toBeInTheDocument();
// Check that no microagents are displayed
expect(screen.queryByText("test-microagent-1")).not.toBeInTheDocument();
expect(screen.queryByText("test-microagent-2")).not.toBeInTheDocument();
});
it("should display microagent cards with correct information", async () => {
@@ -385,10 +423,9 @@ describe("MicroagentManagement", () => {
await user.click(addButtons[0]);
// Check that the modal is opened
const modalTitle = screen.getByText(
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
);
expect(modalTitle).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
});
it("should close add microagent modal when cancel is clicked", async () => {
@@ -405,19 +442,15 @@ describe("MicroagentManagement", () => {
await user.click(addButtons[0]);
// Check that the modal is opened
const modalTitle = screen.getByText(
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
);
expect(modalTitle).toBeInTheDocument();
const closeButton = screen.getByRole("button", { name: "" });
await user.click(closeButton);
// Find and click the cancel button
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
// Check that the modal is closed
expect(
screen.queryByText("MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT"),
).not.toBeInTheDocument();
// Check that modal is closed
await waitFor(() => {
expect(
screen.queryByTestId("add-microagent-modal"),
).not.toBeInTheDocument();
});
});
it("should display empty state when no repositories are found", async () => {
@@ -494,12 +527,12 @@ describe("MicroagentManagement", () => {
// Check that search input is rendered
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute(
"placeholder",
"Search repositories...",
"COMMON$SEARCH_REPOSITORIES...",
);
});
@@ -519,7 +552,7 @@ describe("MicroagentManagement", () => {
// Type in search input to filter further
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, "repo2");
@@ -545,7 +578,7 @@ describe("MicroagentManagement", () => {
// Type in search input with uppercase
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, "REPO2");
@@ -568,7 +601,7 @@ describe("MicroagentManagement", () => {
// Type in search input with partial match
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, "repo");
@@ -594,7 +627,7 @@ describe("MicroagentManagement", () => {
// Type in search input
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, "repo2");
@@ -627,7 +660,7 @@ describe("MicroagentManagement", () => {
// Type in search input with non-existent repository name
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, "nonexistent");
@@ -655,7 +688,7 @@ describe("MicroagentManagement", () => {
// Type in search input with special characters
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, ".openhands");
@@ -676,7 +709,7 @@ describe("MicroagentManagement", () => {
// Filter to show only repo2
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, "repo2");
@@ -711,7 +744,7 @@ describe("MicroagentManagement", () => {
// Type in search input with leading/trailing whitespace
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
await user.type(searchInput, " repo2 ");
@@ -730,7 +763,7 @@ describe("MicroagentManagement", () => {
});
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
name: "COMMON$SEARCH_REPOSITORIES",
});
// Type "repo" - should show repo2
@@ -749,4 +782,679 @@ describe("MicroagentManagement", () => {
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
});
});
// Search conversations functionality tests
describe("Search conversations functionality", () => {
it("should call searchConversations API when repository is expanded", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both microagents and conversations to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(OpenHands.searchConversations).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
});
});
it("should display both microagents and conversations when repository is expanded", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
});
// Check that microagents are displayed
const microagent1 = screen.getByText("test-microagent-1");
const microagent2 = screen.getByText("test-microagent-2");
expect(microagent1).toBeInTheDocument();
expect(microagent2).toBeInTheDocument();
// Check that conversations are displayed
const conversation1 = screen.getByText("Test Conversation 1");
const conversation2 = screen.getByText("Test Conversation 2");
expect(conversation1).toBeInTheDocument();
expect(conversation2).toBeInTheDocument();
});
it("should show loading state when both microagents and conversations are loading", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Make both queries never resolve
getRepositoryMicroagentsSpy.mockImplementation(
() => new Promise(() => {}),
);
searchConversationsSpy.mockImplementation(() => new Promise(() => {}));
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Check that loading spinner is displayed
const loadingSpinner = screen.getByTestId("loading-spinner");
expect(loadingSpinner).toBeInTheDocument();
});
it("should hide loading state when both queries complete", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
});
// Check that loading spinner is not displayed
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
});
it("should display microagent file paths for microagents but not for conversations", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
});
// Check that microagent file paths are displayed for microagents
const microagentFilePath1 = screen.getByText(
".openhands/microagents/test-microagent-1",
);
const microagentFilePath2 = screen.getByText(
".openhands/microagents/test-microagent-2",
);
expect(microagentFilePath1).toBeInTheDocument();
expect(microagentFilePath2).toBeInTheDocument();
// Check that microagent file paths are NOT displayed for conversations
expect(
screen.queryByText(".openhands/microagents/Test Conversation 1"),
).not.toBeInTheDocument();
expect(
screen.queryByText(".openhands/microagents/Test Conversation 2"),
).not.toBeInTheDocument();
});
it("should show learn this repo component when no microagents and no conversations", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Mock both queries to return empty arrays
getRepositoryMicroagentsSpy.mockResolvedValue([]);
searchConversationsSpy.mockResolvedValue([]);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both queries to complete
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed
const learnThisRepo = screen.getByText(
"MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
);
expect(learnThisRepo).toBeInTheDocument();
});
it("should show learn this repo component when only conversations exist but no microagents", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Mock microagents to return empty array, conversations to return data
getRepositoryMicroagentsSpy.mockResolvedValue([]);
searchConversationsSpy.mockResolvedValue([...mockConversations]);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both queries to complete
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
});
// Check that conversations are displayed
const conversation1 = screen.getByText("Test Conversation 1");
const conversation2 = screen.getByText("Test Conversation 2");
expect(conversation1).toBeInTheDocument();
expect(conversation2).toBeInTheDocument();
// Check that learn this repo component is NOT displayed (since we have conversations)
expect(
screen.queryByText("MICROAGENT_MANAGEMENT$LEARN_THIS_REPO"),
).not.toBeInTheDocument();
});
it("should show learn this repo component when only microagents exist but no conversations", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Mock microagents to return data, conversations to return empty array
getRepositoryMicroagentsSpy.mockResolvedValue([...mockMicroagents]);
searchConversationsSpy.mockResolvedValue([]);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both queries to complete
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
});
// Check that microagents are displayed
const microagent1 = screen.getByText("test-microagent-1");
const microagent2 = screen.getByText("test-microagent-2");
expect(microagent1).toBeInTheDocument();
expect(microagent2).toBeInTheDocument();
// Check that learn this repo component is NOT displayed (since we have microagents)
expect(
screen.queryByText("MICROAGENT_MANAGEMENT$LEARN_THIS_REPO"),
).not.toBeInTheDocument();
});
it("should handle error when fetching conversations", async () => {
const user = userEvent.setup();
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
searchConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for the error to be handled
await waitFor(() => {
expect(searchConversationsSpy).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
});
// Check that the learn this repo component is displayed (since conversations failed)
await waitFor(() => {
expect(
screen.getByText("MICROAGENT_MANAGEMENT$LEARN_THIS_REPO"),
).toBeInTheDocument();
});
// Also check that the microagents query was called successfully
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
});
it("should handle error when fetching microagents but conversations succeed", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
getRepositoryMicroagentsSpy.mockRejectedValue(
new Error("Failed to fetch microagents"),
);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for the error to be handled
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith(
"user",
"repo2",
);
});
// Check that the learn this repo component is displayed (since microagents failed)
const learnThisRepo = screen.getByText(
"MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
);
expect(learnThisRepo).toBeInTheDocument();
});
it("should call searchConversations with correct parameters", async () => {
const user = userEvent.setup();
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for searchConversations to be called
await waitFor(() => {
expect(searchConversationsSpy).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
});
});
it("should display conversations with correct information", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
});
// Check that conversations display correct information
const conversation1 = screen.getByText("Test Conversation 1");
const conversation2 = screen.getByText("Test Conversation 2");
expect(conversation1).toBeInTheDocument();
expect(conversation2).toBeInTheDocument();
// Check that created dates are displayed for conversations (there are multiple elements with the same text)
const createdDates = screen.getAllByText(
/COMMON\$CREATED_ON.*10\/01\/2021/,
);
expect(createdDates.length).toBeGreaterThan(0);
const createdDates2 = screen.getAllByText(
/COMMON\$CREATED_ON.*10\/02\/2021/,
);
expect(createdDates2.length).toBeGreaterThan(0);
});
it("should handle multiple repository expansions with conversations", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion1 = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion1);
// Wait for both queries to be called for first repo
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(OpenHands.searchConversations).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
});
// Check that both microagents and conversations are displayed
expect(screen.getByText("test-microagent-1")).toBeInTheDocument();
expect(screen.getByText("test-microagent-2")).toBeInTheDocument();
expect(screen.getByText("Test Conversation 1")).toBeInTheDocument();
expect(screen.getByText("Test Conversation 2")).toBeInTheDocument();
});
});
// Add microagent integration tests
describe("Add microagent functionality", () => {
it("should render add microagent button", async () => {
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that add microagent buttons are present
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
expect(addButtons.length).toBeGreaterThan(0);
});
it("should open modal when add button is clicked", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Check that the modal is opened
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
});
it("should render modal when Redux state is set to visible", async () => {
// Render with modal already visible in Redux state
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagent: null,
addMicroagentModalVisible: true, // Start with modal visible
selectedRepository: {
id: "1",
name: "test-repo",
full_name: "user/test-repo",
private: false,
git_provider: "github",
default_branch: "main",
is_public: true,
} as GitRepository,
personalRepositories: [],
organizationRepositories: [],
repositories: [],
},
},
});
// Check that modal is rendered
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
expect(screen.getByTestId("query-input")).toBeInTheDocument();
expect(screen.getByTestId("cancel-button")).toBeInTheDocument();
expect(screen.getByTestId("confirm-button")).toBeInTheDocument();
});
it("should display form fields in the modal", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Wait for modal to be rendered and check form fields
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Check that form fields are present
expect(screen.getByTestId("query-input")).toBeInTheDocument();
expect(screen.getByTestId("cancel-button")).toBeInTheDocument();
expect(screen.getByTestId("confirm-button")).toBeInTheDocument();
});
it("should disable confirm button when query is empty", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Wait for modal to be rendered
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Check that confirm button is disabled when query is empty
const confirmButton = screen.getByTestId("confirm-button");
expect(confirmButton).toBeDisabled();
});
it("should close modal when cancel button is clicked", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Check that the modal is opened
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Click the close button (X icon)
const closeButton = screen.getByRole("button", { name: "" });
await user.click(closeButton);
// Check that modal is closed
await waitFor(() => {
expect(
screen.queryByTestId("add-microagent-modal"),
).not.toBeInTheDocument();
});
});
it("should enable confirm button when query is entered", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Wait for modal to be rendered and branch to be selected
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Wait for the confirm button to be enabled after entering query and branch selection
const queryInput = screen.getByTestId("query-input");
await user.type(queryInput, "Test query");
await waitFor(() => {
const confirmButton = screen.getByTestId("confirm-button");
expect(confirmButton).not.toBeDisabled();
});
});
it("should prevent form submission when query is empty", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Wait for modal to be rendered
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Try to submit form with empty query
const confirmButton = screen.getByTestId("confirm-button");
await user.click(confirmButton);
// Check that modal is still open (form submission prevented)
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
it("should trim whitespace from query before submission", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Wait for modal to be rendered and branch to be selected
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Enter query with whitespace
const queryInput = screen.getByTestId("query-input");
await user.type(queryInput, " Test query with whitespace ");
// Wait for the confirm button to be enabled after entering query and branch selection
await waitFor(() => {
const confirmButton = screen.getByTestId("confirm-button");
expect(confirmButton).not.toBeDisabled();
});
});
});
});

View File

@@ -13,6 +13,7 @@ import {
GitChange,
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
@@ -251,6 +252,28 @@ class OpenHands {
return data.results;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
): Promise<Conversation[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (selectedRepository) {
params.append("selected_repository", selectedRepository);
}
if (conversationTrigger) {
params.append("conversation_trigger", conversationTrigger);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
`/api/conversations?${params.toString()}`,
);
return data.results;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/conversations/${conversationId}`);
}
@@ -262,6 +285,7 @@ class OpenHands {
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
createMicroagent?: CreateMicroagent,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
@@ -270,6 +294,7 @@ class OpenHands {
initial_user_msg: initialUserMsg,
suggested_task,
conversation_instructions: conversationInstructions,
create_microagent: createMicroagent,
};
const { data } = await openHands.post<Conversation>(

View File

@@ -79,7 +79,11 @@ export interface RepositorySelection {
git_provider: Provider | null;
}
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export type ConversationTrigger =
| "resolver"
| "gui"
| "suggested_task"
| "microagent_management";
export interface Conversation {
conversation_id: string;
@@ -94,6 +98,7 @@ export interface Conversation {
trigger?: ConversationTrigger;
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
}
export interface ResultSet<T> {
@@ -133,3 +138,9 @@ export interface GetMicroagentPromptResponse {
status: string;
prompt: string;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;
title?: string;
}

View File

@@ -1,7 +1,8 @@
import React from "react";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
export interface BranchDropdownProps {
items: { key: React.Key; label: string }[];
@@ -9,6 +10,8 @@ export interface BranchDropdownProps {
onInputChange: (value: string) => void;
isDisabled: boolean;
selectedKey?: string;
wrapperClassName?: string;
label?: ReactNode;
}
export function BranchDropdown({
@@ -17,6 +20,8 @@ export function BranchDropdown({
onInputChange,
isDisabled,
selectedKey,
wrapperClassName,
label,
}: BranchDropdownProps) {
const { t } = useTranslation();
@@ -26,11 +31,12 @@ export function BranchDropdown({
name="branch-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
items={items}
wrapperClassName="max-w-[500px]"
wrapperClassName={cn("max-w-[500px]", wrapperClassName)}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isDisabled={isDisabled}
selectedKey={selectedKey}
label={label}
/>
);
}

View File

@@ -1,12 +1,19 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
export function BranchErrorState() {
interface BranchErrorStateProps {
wrapperClassName?: string;
}
export function BranchErrorState({ wrapperClassName }: BranchErrorStateProps) {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500",
wrapperClassName,
)}
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_BRANCHES")}</span>
</div>

View File

@@ -1,13 +1,22 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { cn } from "#/utils/utils";
export function BranchLoadingState() {
interface BranchLoadingStateProps {
wrapperClassName?: string;
}
export function BranchLoadingState({
wrapperClassName,
}: BranchLoadingStateProps) {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm",
wrapperClassName,
)}
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>

View File

@@ -20,7 +20,7 @@ export function MicroagentManagementAccordionTitle({
{repository.full_name}
</div>
</div>
<MicroagentManagementAddMicroagentButton />
<MicroagentManagementAddMicroagentButton repository={repository} />
</div>
);
}

View File

@@ -1,10 +1,20 @@
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import {
setAddMicroagentModalVisible,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
export function MicroagentManagementAddMicroagentButton() {
interface MicroagentManagementAddMicroagentButtonProps {
repository: GitRepository;
}
export function MicroagentManagementAddMicroagentButton({
repository,
}: MicroagentManagementAddMicroagentButtonProps) {
const { t } = useTranslation();
const { addMicroagentModalVisible } = useSelector(
@@ -16,6 +26,7 @@ export function MicroagentManagementAddMicroagentButton() {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
dispatch(setSelectedRepository(repository));
};
return (

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
@@ -10,30 +10,155 @@ import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { Branch, GitRepository } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import {
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementAddMicroagentModalProps {
onConfirm: () => void;
onConfirm: (formData: MicroagentFormData) => void;
onCancel: () => void;
isLoading: boolean;
}
export function MicroagentManagementAddMicroagentModal({
onConfirm,
onCancel,
isLoading = false,
}: MicroagentManagementAddMicroagentModalProps) {
const { t } = useTranslation();
const [triggers, setTriggers] = useState<string[]>([]);
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const modalTitle = selectedRepository
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${selectedRepository}`
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${(selectedRepository as GitRepository).full_name}`
: t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
@@ -64,6 +189,7 @@ export function MicroagentManagementAddMicroagentModal({
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
{renderBranchSelector()}
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
@@ -73,6 +199,8 @@ export function MicroagentManagementAddMicroagentModal({
required
data-testid="query-input"
name="query-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO)}
rows={6}
className={cn(
@@ -80,19 +208,6 @@ export function MicroagentManagementAddMicroagentModal({
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
<div className="flex items-center gap-2 text-[11px] font-normal text-white leading-[16px]">
<span className="font-semibold">
{t(I18nKey.COMMON$FOR_EXAMPLE)}:
</span>
<span className="underline">
{t(I18nKey.COMMON$TEST_DB_MIGRATION)}
</span>
<span className="underline">{t(I18nKey.COMMON$RUN_TEST)}</span>
<span className="underline">{t(I18nKey.COMMON$RUN_APP)}</span>
<span className="underline">
{t(I18nKey.COMMON$LEARN_FILE_STRUCTURE)}
</span>
</div>
</label>
<label
htmlFor="trigger-input"
@@ -129,17 +244,26 @@ export function MicroagentManagementAddMicroagentModal({
type="button"
variant="secondary"
onClick={onCancel}
data-testid="cancel-button"
testId="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={onConfirm}
data-testid="confirm-button"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
}
>
{t(I18nKey.MICROAGENT$LAUNCH)}
{isLoading || isLoadingBranches
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>

View File

@@ -0,0 +1,193 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "./microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import { MicroagentFormData } from "#/types/microagent-management";
import { AgentState } from "#/types/agent-state";
import { getPR, getProviderName, getPRShort } from "#/utils/utils";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { GitRepository } from "#/types/git";
import { queryClient } from "#/query-client-config";
import { Provider } from "#/types/settings";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
const shouldInvalidateConversationsList = (currentSocketEvent: unknown) => {
const hasError =
isErrorEvent(currentSocketEvent) || isAgentStatusError(currentSocketEvent);
const hasStateChanged =
isOpenHandsEvent(currentSocketEvent) &&
isAgentStateChangeObservation(currentSocketEvent);
const hasFinished =
isOpenHandsEvent(currentSocketEvent) && isFinishAction(currentSocketEvent);
return hasError || hasStateChanged || hasFinished;
};
const getConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- Step 2: Update the markdown file with the content below:
${
formData.triggers &&
formData.triggers.length > 0 &&
`
---
triggers:
${formData.triggers.map((trigger: string) => ` - ${trigger}`).join("\n")}
---
`
}
${formData.query}
- Step 3: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 4: Please push the changes to your branch on ${getProviderName(gitProvider)} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.
`;
export function MicroagentManagementContent() {
const { addMicroagentModalVisible, selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
// Reusable function to invalidate conversations list for a repository
const invalidateConversationsList = React.useCallback(
(repositoryName: string) => {
queryClient.invalidateQueries({
queryKey: [
"conversations",
"search",
repositoryName,
"microagent_management",
],
});
},
[],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown) => {
// Get repository name from selectedRepository for invalidation
const repositoryName =
selectedRepository && typeof selectedRepository === "object"
? (selectedRepository as GitRepository).full_name
: "";
if (shouldInvalidateConversationsList(socketEvent)) {
invalidateConversationsList(repositoryName);
}
},
[invalidateConversationsList, selectedRepository],
);
const handleCreateMicroagent = (formData: MicroagentFormData) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
// Use the GitRepository properties
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const isGitLab = gitProvider === "gitlab";
const pr = getPR(isGitLab);
const prShort = getPRShort(isGitLab);
// Create conversation instructions for microagent generation
const conversationInstructions = getConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
);
// Create the CreateMicroagent object
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
createConversationAndSubscribe({
query: conversationInstructions,
conversationInstructions,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideAddMicroagentModal();
// Invalidate conversations list to fetch the latest conversations for this repository
invalidateConversationsList(repositoryName);
// Also invalidate microagents list to fetch the latest microagents
// Extract owner and repo from full_name (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
queryClient.invalidateQueries({
queryKey: ["repository-microagents", owner, repo],
});
hideAddMicroagentModal();
},
onEventCallback: (event: unknown) => {
// Handle conversation events for real-time status updates
handleMicroagentEvent(event);
},
});
};
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
<MicroagentManagementSidebar />
<MicroagentManagementMain />
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}

View File

@@ -1,34 +1,80 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { ConversationStatus } from "#/types/conversation-status";
import { RuntimeStatus } from "#/types/runtime-status";
interface MicroagentManagementMicroagentCardProps {
microagent: {
id: string;
name: string;
createdAt: string;
conversationStatus?: ConversationStatus;
runtimeStatus?: RuntimeStatus;
prNumber?: number[] | null;
};
showMicroagentFilePath?: boolean;
}
export function MicroagentManagementMicroagentCard({
microagent,
showMicroagentFilePath = true,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const { conversationStatus, runtimeStatus, prNumber } = microagent;
// Format the repository URL to point to the microagent file
const microagentFilePath = `.openhands/microagents/${microagent.name}`;
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt));
const hasPr = prNumber && prNumber.length > 0;
// Helper function to get status text
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
) {
return t(I18nKey.COMMON$STOPPED);
}
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (
(conversationStatus === "STARTING" || conversationStatus === "RUNNING") &&
runtimeStatus === "STATUS$READY"
) {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
return (
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
<div className="text-white text-sm font-normal">{microagentFilePath}</div>
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
{showMicroagentFilePath && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
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 { LoadingSpinner } from "#/components/shared/loading-spinner";
export interface RepoMicroagent {
@@ -21,10 +22,26 @@ export function MicroagentManagementRepoMicroagents({
const {
data: microagents,
isLoading,
isError,
isLoading: isLoadingMicroagents,
isError: isErrorMicroagents,
} = useRepositoryMicroagents(owner, repo);
const {
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(
repoMicroagent.repositoryName,
"microagent_management",
1000,
);
// Show loading only when both queries are loading
const isLoading = isLoadingMicroagents || isLoadingConversations;
// Show error UI.
const isError = isErrorMicroagents || isErrorConversations;
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
@@ -33,6 +50,7 @@ export function MicroagentManagementRepoMicroagents({
);
}
// If there's an error with microagents, show the learn this repo component
if (isError) {
return (
<div className="pb-4">
@@ -44,14 +62,18 @@ export function MicroagentManagementRepoMicroagents({
}
const numberOfMicroagents = microagents?.length || 0;
const numberOfConversations = conversations?.length || 0;
const totalItems = numberOfMicroagents + numberOfConversations;
return (
<div className="pb-4">
{numberOfMicroagents === 0 && (
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{/* Render microagents */}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
@@ -64,6 +86,24 @@ export function MicroagentManagementRepoMicroagents({
/>
</div>
))}
{/* Render conversations */}
{numberOfConversations > 0 &&
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={{
id: conversation.conversation_id,
name: conversation.title,
createdAt: conversation.created_at,
conversationStatus: conversation.status,
runtimeStatus: conversation.runtime_status || undefined,
prNumber: conversation.pr_number || undefined,
}}
showMicroagentFilePath={false}
/>
</div>
))}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
interface CreateConversationVariables {
query?: string;
@@ -13,6 +14,7 @@ interface CreateConversationVariables {
};
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
createMicroagent?: CreateMicroagent;
}
export const useCreateConversation = () => {
@@ -21,8 +23,13 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: CreateConversationVariables) => {
const { query, repository, suggestedTask, conversationInstructions } =
variables;
const {
query,
repository,
suggestedTask,
conversationInstructions,
createMicroagent,
} = variables;
return OpenHands.createConversation(
repository?.name,
@@ -31,6 +38,7 @@ export const useCreateConversation = () => {
suggestedTask,
repository?.branch,
conversationInstructions,
createMicroagent,
);
},
onSuccess: async (_, { query, repository }) => {

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useSearchConversations = (
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
) =>
useQuery({
queryKey: [
"conversations",
"search",
selectedRepository,
conversationTrigger,
limit,
],
queryFn: () =>
OpenHands.searchConversations(
selectedRepository,
conversationTrigger,
limit,
),
enabled: true, // Always enabled since parameters are optional
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -3,6 +3,7 @@ import { useCreateConversation } from "./mutation/use-create-conversation";
import { useUserProviders } from "./use-user-providers";
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
@@ -24,6 +25,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
onSuccessCallback,
onEventCallback,
}: {
@@ -34,6 +36,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
branch: string;
gitProvider: Provider;
};
createMicroagent?: CreateMicroagent;
onSuccessCallback?: (conversationId: string) => void;
onEventCallback?: (event: unknown, conversationId: string) => void;
}) => {
@@ -42,6 +45,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
},
{
onSuccess: (data) => {

View File

@@ -12,6 +12,7 @@ export enum I18nKey {
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
MICROAGENT$STATUS_OPENING_PR = "MICROAGENT$STATUS_OPENING_PR",
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
@@ -712,4 +713,8 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
COMMON$READY_FOR_REVIEW = "COMMON$READY_FOR_REVIEW",
COMMON$COMPLETED = "COMMON$COMPLETED",
COMMON$COMPLETED_PARTIALLY = "COMMON$COMPLETED_PARTIALLY",
COMMON$STOPPED = "COMMON$STOPPED",
}

View File

@@ -191,6 +191,22 @@
"de": "Microagent wird geändert...",
"uk": "Зміна мікроагента..."
},
"MICROAGENT$STATUS_OPENING_PR": {
"en": "Opening PR",
"ja": "PRを開いています",
"zh-CN": "正在打开PR",
"zh-TW": "正在打開PR",
"ko-KR": "PR 열는 중",
"no": "Åpner PR",
"it": "Apertura PR",
"pt": "Abrindo PR",
"es": "Abriendo PR",
"ar": "فتح PR",
"fr": "Ouverture de la PR",
"tr": "PR açılıyor",
"de": "PR wird geöffnet",
"uk": "Відкриття PR"
},
"MICROAGENT$STATUS_COMPLETED": {
"en": "View microagent update",
"ja": "マイクロエージェントの更新を表示",
@@ -11390,5 +11406,69 @@
"tr": "Depo ara",
"de": "Repositorys durchsuchen",
"uk": "Пошук репозиторіїв"
},
"COMMON$READY_FOR_REVIEW": {
"en": "Ready for review",
"ja": "レビューの準備ができました",
"zh-CN": "准备好审核",
"zh-TW": "已準備好審查",
"ko-KR": "검토 준비 완료",
"no": "Klar for gjennomgang",
"it": "Pronto per la revisione",
"pt": "Pronto para revisão",
"es": "Listo para revisión",
"ar": "جاهز للمراجعة",
"fr": "Prêt pour la relecture",
"tr": "İncelemeye hazır",
"de": "Bereit zur Überprüfung",
"uk": "Готово до перегляду"
},
"COMMON$COMPLETED": {
"en": "Completed",
"ja": "完了",
"zh-CN": "已完成",
"zh-TW": "已完成",
"ko-KR": "완료됨",
"no": "Fullført",
"it": "Completato",
"pt": "Concluído",
"es": "Completado",
"ar": "مكتمل",
"fr": "Terminé",
"tr": "Tamamlandı",
"de": "Abgeschlossen",
"uk": "Завершено"
},
"COMMON$COMPLETED_PARTIALLY": {
"en": "Completed partially",
"ja": "一部完了",
"zh-CN": "部分完成",
"zh-TW": "部分完成",
"ko-KR": "부분적으로 완료됨",
"no": "Delvis fullført",
"it": "Completato parzialmente",
"pt": "Concluído parcialmente",
"es": "Completado parcialmente",
"ar": "مكتمل جزئيًا",
"fr": "Partiellement terminé",
"tr": "Kısmen tamamlandı",
"de": "Teilweise abgeschlossen",
"uk": "Частково завершено"
},
"COMMON$STOPPED": {
"en": "Stopped",
"ja": "停止しました",
"zh-CN": "已停止",
"zh-TW": "已停止",
"ko-KR": "중지됨",
"no": "Stoppet",
"it": "Interrotto",
"pt": "Parado",
"es": "Detenido",
"ar": "متوقف",
"fr": "Arrêté",
"tr": "Durduruldu",
"de": "Gestoppt",
"uk": "Зупинено"
}
}

View File

@@ -1,14 +1,11 @@
import { redirect } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "#/components/features/microagent-management/microagent-management-sidebar";
import { Route } from "./+types/settings";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "#/components/features/microagent-management/microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { MicroagentManagementContent } from "#/components/features/microagent-management/microagent-management-content";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { EventHandler } from "#/wrapper/event-handler";
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
@@ -31,31 +28,12 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
};
function MicroagentManagement() {
const { addMicroagentModalVisible } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
<MicroagentManagementSidebar />
<MicroagentManagementMain />
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={() => {
hideAddMicroagentModal();
}}
onCancel={() => {
hideAddMicroagentModal();
}}
/>
)}
</div>
<ConversationSubscriptionsProvider>
<EventHandler>
<MicroagentManagementContent />
</EventHandler>
</ConversationSubscriptionsProvider>
);
}

View File

@@ -6,7 +6,7 @@ export const microagentManagementSlice = createSlice({
initialState: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null,
selectedRepository: null as GitRepository | null,
personalRepositories: [] as GitRepository[],
organizationRepositories: [] as GitRepository[],
repositories: [] as GitRepository[],

View File

@@ -10,3 +10,9 @@ export interface RepositoryMicroagent {
created_at: string;
git_provider: string;
}
export interface MicroagentFormData {
query: string;
triggers: string[];
selectedBranch: string;
}

View File

@@ -116,3 +116,29 @@ export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
return "";
}
};
/**
* Get the name of the git provider
* @param gitProvider The git provider
* @returns The name of the git provider
*/
export const getProviderName = (gitProvider: Provider) => {
if (gitProvider === "gitlab") return "GitLab";
if (gitProvider === "bitbucket") return "Bitbucket";
return "GitHub";
};
/**
* Get the name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The name of the PR
*/
export const getPR = (isGitLab: boolean) =>
isGitLab ? "merge request" : "pull request";
/**
* Get the short name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The short name of the PR
*/
export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR");

View File

@@ -27,3 +27,4 @@ class ConversationInfo:
url: str | None = None
session_api_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
pr_number: list[int] = field(default_factory=list)

View File

@@ -424,6 +424,7 @@ async def _get_conversation_info(
num_connections=num_connections,
url=agent_loop_info.url if agent_loop_info else None,
session_api_key=getattr(agent_loop_info, 'session_api_key', None),
pr_number=conversation.pr_number,
)
except Exception as e:
logger.error(

View File

@@ -179,6 +179,7 @@ async def test_search_conversations():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
]
)
@@ -638,6 +639,7 @@ async def test_get_conversation():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
assert conversation == expected
@@ -1198,3 +1200,365 @@ async def test_new_conversation_with_create_microagent_minimal(provider_handler_
assert (
call_args['git_provider'] is None
) # Should remain None since not set in create_microagent
@pytest.mark.asyncio
async def test_search_conversations_with_pr_number():
"""Test searching conversations includes pr_number field in response."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[123, 456], # Multiple PR numbers
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [123, 456]
assert conversation_info.conversation_id == 'conversation_with_pr'
assert conversation_info.title == 'Conversation with PR'
@pytest.mark.asyncio
async def test_search_conversations_with_empty_pr_number():
"""Test searching conversations with empty pr_number field."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_no_pr',
title='Conversation without PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[], # Empty PR numbers list
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes empty pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == []
assert conversation_info.conversation_id == 'conversation_no_pr'
assert conversation_info.title == 'Conversation without PR'
@pytest.mark.asyncio
async def test_search_conversations_with_single_pr_number():
"""Test searching conversations with single PR number."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_single_pr',
title='Conversation with Single PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[789], # Single PR number
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes single pr_number
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [789]
assert conversation_info.conversation_id == 'conversation_single_pr'
assert conversation_info.title == 'Conversation with Single PR'
@pytest.mark.asyncio
async def test_get_conversation_with_pr_number():
"""Test getting a single conversation includes pr_number field."""
with _patch_store():
# Mock the conversation store
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
selected_repository='test/repo',
user_id='12345',
pr_number=[123, 456, 789], # Multiple PR numbers
)
)
# Mock the conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
mock_manager.get_connections = AsyncMock(return_value={})
mock_manager.get_agent_loop_info = AsyncMock(return_value=[])
conversation = await get_conversation(
'conversation_with_pr', conversation_store=mock_store
)
expected = ConversationInfo(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
status=ConversationStatus.STOPPED,
selected_repository='test/repo',
num_connections=0,
url=None,
pr_number=[123, 456, 789], # Should include PR numbers
)
assert conversation == expected
@pytest.mark.asyncio
async def test_search_conversations_multiple_with_pr_numbers():
"""Test searching conversations with multiple conversations having different PR numbers."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_1',
title='Conversation 1',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[100, 200], # Multiple PR numbers
),
ConversationMetadata(
conversation_id='conversation_2',
title='Conversation 2',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[], # Empty PR numbers
),
ConversationMetadata(
conversation_id='conversation_3',
title='Conversation 3',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[300], # Single PR number
),
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify all results include pr_number field
assert len(result_set.results) == 3
# Check first conversation
assert result_set.results[0].conversation_id == 'conversation_1'
assert result_set.results[0].pr_number == [100, 200]
# Check second conversation
assert result_set.results[1].conversation_id == 'conversation_2'
assert result_set.results[1].pr_number == []
# Check third conversation
assert result_set.results[2].conversation_id == 'conversation_3'
assert result_set.results[2].pr_number == [300]