mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
feat(frontend): Integrate with the API to add a new microagent. (#9821)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function MicroagentManagementAccordionTitle({
|
||||
{repository.full_name}
|
||||
</div>
|
||||
</div>
|
||||
<MicroagentManagementAddMicroagentButton />
|
||||
<MicroagentManagementAddMicroagentButton repository={repository} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
26
frontend/src/hooks/query/use-search-conversations.ts
Normal file
26
frontend/src/hooks/query/use-search-conversations.ts
Normal 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
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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": "Зупинено"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -10,3 +10,9 @@ export interface RepositoryMicroagent {
|
||||
created_at: string;
|
||||
git_provider: string;
|
||||
}
|
||||
|
||||
export interface MicroagentFormData {
|
||||
query: string;
|
||||
triggers: string[];
|
||||
selectedBranch: string;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user