feat: show available skills for v1 conversations (#12039)

This commit is contained in:
Hiep Le 2025-12-17 23:25:10 +07:00 committed by GitHub
parent f98e7fbc49
commit 9ef11bf930
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1325 additions and 489 deletions

View File

@ -63,7 +63,7 @@ Frontend:
- We use TanStack Query (fka React Query) for data fetching and cache management
- Data Access Layer: API client methods are located in `frontend/src/api` and should never be called directly from UI components - they must always be wrapped with TanStack Query
- Custom hooks are located in `frontend/src/hooks/query/` and `frontend/src/hooks/mutation/`
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationMicroagents`)
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints

View File

@ -42,7 +42,7 @@ vi.mock("react-i18next", async () => {
BUTTON$EXPORT_CONVERSATION: "Export Conversation",
BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code",
BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools",
CONVERSATION$SHOW_MICROAGENTS: "Show Microagents",
CONVERSATION$SHOW_SKILLS: "Show Skills",
BUTTON$DISPLAY_COST: "Display Cost",
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
"Close Conversation (Stop Runtime)",
@ -290,7 +290,7 @@ describe("ConversationNameContextMenu", () => {
onStop: vi.fn(),
onDisplayCost: vi.fn(),
onShowAgentTools: vi.fn(),
onShowMicroagents: vi.fn(),
onShowSkills: vi.fn(),
onExportConversation: vi.fn(),
onDownloadViaVSCode: vi.fn(),
};
@ -304,7 +304,7 @@ describe("ConversationNameContextMenu", () => {
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
expect(screen.getByTestId("display-cost-button")).toBeInTheDocument();
expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument();
expect(screen.getByTestId("show-microagents-button")).toBeInTheDocument();
expect(screen.getByTestId("show-skills-button")).toBeInTheDocument();
expect(
screen.getByTestId("export-conversation-button"),
).toBeInTheDocument();
@ -321,9 +321,7 @@ describe("ConversationNameContextMenu", () => {
expect(
screen.queryByTestId("show-agent-tools-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("show-microagents-button"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("show-skills-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("export-conversation-button"),
).not.toBeInTheDocument();
@ -410,19 +408,19 @@ describe("ConversationNameContextMenu", () => {
it("should call show microagents handler when show microagents button is clicked", async () => {
const user = userEvent.setup();
const onShowMicroagents = vi.fn();
const onShowSkills = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onShowMicroagents={onShowMicroagents}
onShowSkills={onShowSkills}
/>,
);
const showMicroagentsButton = screen.getByTestId("show-microagents-button");
const showMicroagentsButton = screen.getByTestId("show-skills-button");
await user.click(showMicroagentsButton);
expect(onShowMicroagents).toHaveBeenCalledTimes(1);
expect(onShowSkills).toHaveBeenCalledTimes(1);
});
it("should call export conversation handler when export conversation button is clicked", async () => {
@ -519,7 +517,7 @@ describe("ConversationNameContextMenu", () => {
onStop: vi.fn(),
onDisplayCost: vi.fn(),
onShowAgentTools: vi.fn(),
onShowMicroagents: vi.fn(),
onShowSkills: vi.fn(),
onExportConversation: vi.fn(),
onDownloadViaVSCode: vi.fn(),
};
@ -541,8 +539,8 @@ describe("ConversationNameContextMenu", () => {
expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent(
"Show Agent Tools",
);
expect(screen.getByTestId("show-microagents-button")).toHaveTextContent(
"Show Microagents",
expect(screen.getByTestId("show-skills-button")).toHaveTextContent(
"Show Skills",
);
expect(screen.getByTestId("export-conversation-button")).toHaveTextContent(
"Export Conversation",

View File

@ -1,91 +0,0 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation ID hook
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
describe("MicroagentsModal - Refresh Button", () => {
const mockOnClose = vi.fn();
const conversationId = "test-conversation-id";
const defaultProps = {
onClose: mockOnClose,
conversationId,
};
const mockMicroagents = [
{
name: "Test Agent 1",
type: "repo" as const,
triggers: ["test", "example"],
content: "This is test content for agent 1",
},
{
name: "Test Agent 2",
type: "knowledge" as const,
triggers: ["help", "support"],
content: "This is test content for agent 2",
},
];
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
// Setup default mock for getMicroagents
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
microagents: mockMicroagents,
});
// Mock the agent state to return a ready state
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Refresh Button Rendering", () => {
it("should render the refresh button with correct text and test ID", async () => {
renderWithProviders(<MicroagentsModal {...defaultProps} />);
// Wait for the component to load and render the refresh button
const refreshButton = await screen.findByTestId("refresh-microagents");
expect(refreshButton).toBeInTheDocument();
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
});
});
describe("Refresh Button Functionality", () => {
it("should call refetch when refresh button is clicked", async () => {
const user = userEvent.setup();
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
renderWithProviders(<MicroagentsModal {...defaultProps} />);
// Wait for the component to load and render the refresh button
const refreshButton = await screen.findByTestId("refresh-microagents");
refreshSpy.mockClear();
await user.click(refreshButton);
expect(refreshSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,394 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { SkillsModal } from "#/components/features/conversation-panel/skills-modal";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import SettingsService from "#/api/settings-service/settings-service.api";
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation ID hook
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
describe("SkillsModal - Refresh Button", () => {
const mockOnClose = vi.fn();
const conversationId = "test-conversation-id";
const defaultProps = {
onClose: mockOnClose,
conversationId,
};
const mockSkills = [
{
name: "Test Agent 1",
type: "repo" as const,
triggers: ["test", "example"],
content: "This is test content for agent 1",
},
{
name: "Test Agent 2",
type: "knowledge" as const,
triggers: ["help", "support"],
content: "This is test content for agent 2",
},
];
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
// Setup default mock for getMicroagents (V0)
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
microagents: mockSkills,
});
// Mock the agent state to return a ready state
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Refresh Button Rendering", () => {
it("should render the refresh button with correct text and test ID", async () => {
renderWithProviders(<SkillsModal {...defaultProps} />);
// Wait for the component to load and render the refresh button
const refreshButton = await screen.findByTestId("refresh-skills");
expect(refreshButton).toBeInTheDocument();
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
});
});
describe("Refresh Button Functionality", () => {
it("should call refetch when refresh button is clicked", async () => {
const user = userEvent.setup();
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
renderWithProviders(<SkillsModal {...defaultProps} />);
// Wait for the component to load and render the refresh button
const refreshButton = await screen.findByTestId("refresh-skills");
// Clear previous calls to only track the click
refreshSpy.mockClear();
await user.click(refreshButton);
// Verify the refresh triggered a new API call
expect(refreshSpy).toHaveBeenCalled();
});
});
});
describe("useConversationSkills - V1 API Integration", () => {
const conversationId = "test-conversation-id";
const mockMicroagents = [
{
name: "V0 Test Agent",
type: "repo" as const,
triggers: ["v0"],
content: "V0 skill content",
},
];
const mockSkills = [
{
name: "V1 Test Skill",
type: "knowledge" as const,
triggers: ["v1", "skill"],
content: "V1 skill content",
},
];
beforeEach(() => {
vi.clearAllMocks();
// Mock agent state
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("V0 API Usage (v1_enabled: false)", () => {
it("should call v0 ConversationService.getMicroagents when v1_enabled is false", async () => {
// Arrange
const getMicroagentsSpy = vi
.spyOn(ConversationService, "getMicroagents")
.mockResolvedValue({ microagents: mockMicroagents });
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: false,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
// Assert
await screen.findByText("V0 Test Agent");
expect(getMicroagentsSpy).toHaveBeenCalledWith(conversationId);
expect(getMicroagentsSpy).toHaveBeenCalledTimes(1);
});
it("should display v0 skills correctly", async () => {
// Arrange
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
microagents: mockMicroagents,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: false,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
// Assert
const agentName = await screen.findByText("V0 Test Agent");
expect(agentName).toBeInTheDocument();
});
});
describe("V1 API Usage (v1_enabled: true)", () => {
it("should call v1 V1ConversationService.getSkills when v1_enabled is true", async () => {
// Arrange
const getSkillsSpy = vi
.spyOn(V1ConversationService, "getSkills")
.mockResolvedValue({ skills: mockSkills });
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
// Assert
await screen.findByText("V1 Test Skill");
expect(getSkillsSpy).toHaveBeenCalledWith(conversationId);
expect(getSkillsSpy).toHaveBeenCalledTimes(1);
});
it("should display v1 skills correctly", async () => {
// Arrange
vi.spyOn(V1ConversationService, "getSkills").mockResolvedValue({
skills: mockSkills,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
// Assert
const skillName = await screen.findByText("V1 Test Skill");
expect(skillName).toBeInTheDocument();
});
it("should use v1 API when v1_enabled is true", async () => {
// Arrange
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
const getSkillsSpy = vi
.spyOn(V1ConversationService, "getSkills")
.mockResolvedValue({
skills: mockSkills,
});
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
// Assert
await screen.findByText("V1 Test Skill");
// Verify v1 API was called
expect(getSkillsSpy).toHaveBeenCalledWith(conversationId);
});
});
describe("API Switching on Settings Change", () => {
it("should refetch using different API when v1_enabled setting changes", async () => {
// Arrange
const getMicroagentsSpy = vi
.spyOn(ConversationService, "getMicroagents")
.mockResolvedValue({ microagents: mockMicroagents });
const getSkillsSpy = vi
.spyOn(V1ConversationService, "getSkills")
.mockResolvedValue({ skills: mockSkills });
const settingsSpy = vi
.spyOn(SettingsService, "getSettings")
.mockResolvedValue({
v1_enabled: false,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
// Act - Initial render with v1_enabled: false
const { rerender } = renderWithProviders(
<SkillsModal onClose={vi.fn()} />,
);
// Assert - v0 API called initially
await screen.findByText("V0 Test Agent");
expect(getMicroagentsSpy).toHaveBeenCalledWith(conversationId);
// Arrange - Change settings to v1_enabled: true
settingsSpy.mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
// Act - Force re-render
rerender(<SkillsModal onClose={vi.fn()} />);
// Assert - v1 API should be called after settings change
await screen.findByText("V1 Test Skill");
expect(getSkillsSpy).toHaveBeenCalledWith(conversationId);
});
});
});

View File

@ -11,6 +11,7 @@ import type {
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
GetSkillsResponse,
} from "./v1-conversation-service.types";
class V1ConversationService {
@ -315,6 +316,18 @@ class V1ConversationService {
);
return data;
}
/**
* Get all skills associated with a V1 conversation
* @param conversationId The conversation ID
* @returns The available skills associated with the conversation
*/
static async getSkills(conversationId: string): Promise<GetSkillsResponse> {
const { data } = await openHands.get<GetSkillsResponse>(
`/api/v1/app-conversations/${conversationId}/skills`,
);
return data;
}
}
export default V1ConversationService;

View File

@ -99,3 +99,14 @@ export interface V1AppConversation {
conversation_url: string | null;
session_api_key: string | null;
}
export interface Skill {
name: string;
type: "repo" | "knowledge";
content: string;
triggers: string[];
}
export interface GetSkillsResponse {
skills: Skill[];
}

View File

@ -26,14 +26,14 @@ const contextMenuListItemClassName = cn(
interface ToolsContextMenuProps {
onClose: () => void;
onShowMicroagents: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowSkills: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools: (event: React.MouseEvent<HTMLButtonElement>) => void;
shouldShowAgentTools?: boolean;
}
export function ToolsContextMenu({
onClose,
onShowMicroagents,
onShowSkills,
onShowAgentTools,
shouldShowAgentTools = true,
}: ToolsContextMenuProps) {
@ -41,7 +41,6 @@ export function ToolsContextMenu({
const { data: conversation } = useActiveConversation();
const { providers } = useUserProviders();
// TODO: Hide microagent menu items for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
@ -130,20 +129,17 @@ export function ToolsContextMenu({
{(!isV1Conversation || shouldShowAgentTools) && <Divider />}
{/* Show Available Microagents - Hidden for V1 conversations */}
{!isV1Conversation && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
testId="show-skills-button"
onClick={onShowSkills}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<RobotIcon width={16} height={16} />}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
)}
{/* Show Agent Tools and Metadata - Only show if system message is available */}
{shouldShowAgentTools && (

View File

@ -7,7 +7,7 @@ import { ToolsContextMenu } from "./tools-context-menu";
import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
import { MicroagentsModal } from "../conversation-panel/microagents-modal";
import { SkillsModal } from "../conversation-panel/skills-modal";
export function Tools() {
const { t } = useTranslation();
@ -17,11 +17,11 @@ export function Tools() {
const {
handleShowAgentTools,
handleShowMicroagents,
handleShowSkills,
systemModalVisible,
setSystemModalVisible,
microagentsModalVisible,
setMicroagentsModalVisible,
skillsModalVisible,
setSkillsModalVisible,
systemMessage,
shouldShowAgentTools,
} = useConversationNameContextMenu({
@ -51,7 +51,7 @@ export function Tools() {
{contextMenuOpen && (
<ToolsContextMenu
onClose={() => setContextMenuOpen(false)}
onShowMicroagents={handleShowMicroagents}
onShowSkills={handleShowSkills}
onShowAgentTools={handleShowAgentTools}
shouldShowAgentTools={shouldShowAgentTools}
/>
@ -64,9 +64,9 @@ export function Tools() {
systemMessage={systemMessage ? systemMessage.args : null}
/>
{/* Microagents Modal */}
{microagentsModalVisible && (
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
{/* Skills Modal */}
{skillsModalVisible && (
<SkillsModal onClose={() => setSkillsModalVisible(false)} />
)}
</div>
);

View File

@ -1,147 +0,0 @@
import {
Trash,
Power,
Pencil,
Download,
Wallet,
Wrench,
Bot,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { Divider } from "#/ui/divider";
import { I18nKey } from "#/i18n/declaration";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowMicroagents?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
export function ConversationCardContextMenu({
onClose,
onDelete,
onStop,
onEdit,
onDisplayCost,
onShowAgentTools,
onShowMicroagents,
onDownloadViaVSCode,
position = "bottom",
}: ConversationCardContextMenuProps) {
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { data: conversation } = useActiveConversation();
// TODO: Hide microagent menu items for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
const hasEdit = Boolean(onEdit);
const hasDownload = Boolean(onDownloadViaVSCode);
const hasTools = Boolean(onShowAgentTools || onShowMicroagents);
const hasInfo = Boolean(onDisplayCost);
const hasControl = Boolean(onStop || onDelete);
return (
<ContextMenu
ref={ref}
testId="context-menu"
className={cn(
"right-0 absolute mt-3",
position === "top" && "bottom-full",
position === "bottom" && "top-full",
)}
>
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
<ContextMenuIconText
icon={Pencil}
text={t(I18nKey.BUTTON$EDIT_TITLE)}
/>
</ContextMenuListItem>
)}
{hasEdit && (hasDownload || hasTools || hasInfo || hasControl) && (
<Divider />
)}
{onDownloadViaVSCode && (
<ContextMenuListItem
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
<ContextMenuIconText
icon={Download}
text={t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
/>
</ContextMenuListItem>
)}
{hasDownload && (hasTools || hasInfo || hasControl) && <Divider />}
{onShowAgentTools && (
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
<ContextMenuIconText
icon={Wrench}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
/>
</ContextMenuListItem>
)}
{onShowMicroagents && !isV1Conversation && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
>
<ContextMenuIconText
icon={Bot}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
/>
</ContextMenuListItem>
)}
{hasTools && (hasInfo || hasControl) && <Divider />}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
<ContextMenuIconText
icon={Wallet}
text={t(I18nKey.BUTTON$DISPLAY_COST)}
/>
</ContextMenuListItem>
)}
{hasInfo && hasControl && <Divider />}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$PAUSE)} />
</ContextMenuListItem>
)}
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
<ContextMenuIconText icon={Trash} text={t(I18nKey.BUTTON$DELETE)} />
</ContextMenuListItem>
)}
</ContextMenu>
);
}

View File

@ -22,7 +22,7 @@ interface ConversationCardContextMenuProps {
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowMicroagents?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@ -37,7 +37,7 @@ export function ConversationCardContextMenu({
onEdit,
onDisplayCost,
onShowAgentTools,
onShowMicroagents,
onShowSkills,
onDownloadViaVSCode,
position = "bottom",
}: ConversationCardContextMenuProps) {
@ -96,15 +96,15 @@ export function ConversationCardContextMenu({
/>
</ContextMenuListItem>
),
onShowMicroagents && (
onShowSkills && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
testId="show-skills-button"
onClick={onShowSkills}
className={contextMenuListItemClassName}
>
<ConversationNameContextMenuIconText
icon={<RobotIcon width={16} height={16} />}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}
/>
</ContextMenuListItem>
),

View File

@ -3,17 +3,17 @@ import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
import { Pre } from "#/ui/pre";
interface MicroagentContentProps {
interface SkillContentProps {
content: string;
}
export function MicroagentContent({ content }: MicroagentContentProps) {
export function SkillContent({ content }: SkillContentProps) {
const { t } = useTranslation();
return (
<div className="mt-2">
<Typography.Text className="text-sm font-semibold text-gray-300 mb-2">
{t(I18nKey.MICROAGENTS_MODAL$CONTENT)}
{t(I18nKey.COMMON$CONTENT)}
</Typography.Text>
<Pre
size="default"
@ -28,7 +28,7 @@ export function MicroagentContent({ content }: MicroagentContentProps) {
overflow="auto"
className="mt-2"
>
{content || t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
{content || t(I18nKey.SKILLS_MODAL$NO_CONTENT)}
</Pre>
</div>
);

View File

@ -1,35 +1,31 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { Microagent } from "#/api/open-hands.types";
import { Typography } from "#/ui/typography";
import { MicroagentTriggers } from "./microagent-triggers";
import { MicroagentContent } from "./microagent-content";
import { SkillTriggers } from "./skill-triggers";
import { SkillContent } from "./skill-content";
import { Skill } from "#/api/conversation-service/v1-conversation-service.types";
interface MicroagentItemProps {
agent: Microagent;
interface SkillItemProps {
skill: Skill;
isExpanded: boolean;
onToggle: (agentName: string) => void;
}
export function MicroagentItem({
agent,
isExpanded,
onToggle,
}: MicroagentItemProps) {
export function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
return (
<div className="rounded-md overflow-hidden">
<button
type="button"
onClick={() => onToggle(agent.name)}
onClick={() => onToggle(skill.name)}
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<Typography.Text className="font-bold text-gray-100">
{agent.name}
{skill.name}
</Typography.Text>
</div>
<div className="flex items-center">
<Typography.Text className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
{agent.type === "repo" ? "Repository" : "Knowledge"}
{skill.type === "repo" ? "Repository" : "Knowledge"}
</Typography.Text>
<Typography.Text className="text-gray-300">
{isExpanded ? (
@ -43,8 +39,8 @@ export function MicroagentItem({
{isExpanded && (
<div className="px-2 pb-3 pt-1">
<MicroagentTriggers triggers={agent.triggers} />
<MicroagentContent content={agent.content} />
<SkillTriggers triggers={skill.triggers} />
<SkillContent content={skill.content} />
</div>
)}
</div>

View File

@ -2,11 +2,11 @@ import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface MicroagentTriggersProps {
interface SkillTriggersProps {
triggers: string[];
}
export function MicroagentTriggers({ triggers }: MicroagentTriggersProps) {
export function SkillTriggers({ triggers }: SkillTriggersProps) {
const { t } = useTranslation();
if (!triggers || triggers.length === 0) {
@ -16,7 +16,7 @@ export function MicroagentTriggers({ triggers }: MicroagentTriggersProps) {
return (
<div className="mt-2 mb-3">
<Typography.Text className="text-sm font-semibold text-gray-300 mb-2">
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
{t(I18nKey.COMMON$TRIGGERS)}
</Typography.Text>
<div className="flex flex-wrap gap-1">
{triggers.map((trigger) => (

View File

@ -2,19 +2,19 @@ import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface MicroagentsEmptyStateProps {
interface SkillsEmptyStateProps {
isError: boolean;
}
export function MicroagentsEmptyState({ isError }: MicroagentsEmptyStateProps) {
export function SkillsEmptyState({ isError }: SkillsEmptyStateProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center h-full p-4">
<Typography.Text className="text-gray-400">
{isError
? t(I18nKey.MICROAGENTS_MODAL$FETCH_ERROR)
: t(I18nKey.CONVERSATION$NO_MICROAGENTS)}
? t(I18nKey.COMMON$FETCH_ERROR)
: t(I18nKey.CONVERSATION$NO_SKILLS)}
</Typography.Text>
</div>
);

View File

@ -1,4 +1,4 @@
export function MicroagentsLoadingState() {
export function SkillsLoadingState() {
return (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary" />

View File

@ -4,28 +4,28 @@ import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/b
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
interface MicroagentsModalHeaderProps {
interface SkillsModalHeaderProps {
isAgentReady: boolean;
isLoading: boolean;
isRefetching: boolean;
onRefresh: () => void;
}
export function MicroagentsModalHeader({
export function SkillsModalHeader({
isAgentReady,
isLoading,
isRefetching,
onRefresh,
}: MicroagentsModalHeaderProps) {
}: SkillsModalHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex items-center justify-between w-full">
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
<BaseModalTitle title={t(I18nKey.SKILLS_MODAL$TITLE)} />
{isAgentReady && (
<BrandButton
testId="refresh-microagents"
testId="refresh-skills"
type="button"
variant="primary"
className="flex items-center gap-2"

View File

@ -3,43 +3,32 @@ import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { I18nKey } from "#/i18n/declaration";
import { useConversationMicroagents } from "#/hooks/query/use-conversation-microagents";
import { useConversationSkills } from "#/hooks/query/use-conversation-skills";
import { AgentState } from "#/types/agent-state";
import { Typography } from "#/ui/typography";
import { MicroagentsModalHeader } from "./microagents-modal-header";
import { MicroagentsLoadingState } from "./microagents-loading-state";
import { MicroagentsEmptyState } from "./microagents-empty-state";
import { MicroagentItem } from "./microagent-item";
import { SkillsModalHeader } from "./skills-modal-header";
import { SkillsLoadingState } from "./skills-loading-state";
import { SkillsEmptyState } from "./skills-empty-state";
import { SkillItem } from "./skill-item";
import { useAgentState } from "#/hooks/use-agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface MicroagentsModalProps {
interface SkillsModalProps {
onClose: () => void;
}
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
export function SkillsModal({ onClose }: SkillsModalProps) {
const { t } = useTranslation();
const { curAgentState } = useAgentState();
const { data: conversation } = useActiveConversation();
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
{},
);
const {
data: microagents,
data: skills,
isLoading,
isError,
refetch,
isRefetching,
} = useConversationMicroagents();
// TODO: Hide MicroagentsModal for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
} = useConversationSkills();
const toggleAgent = (agentName: string) => {
setExpandedAgents((prev) => ({
@ -57,9 +46,9 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
<ModalBody
width="medium"
className="max-h-[80vh] flex flex-col items-start"
testID="microagents-modal"
testID="skills-modal"
>
<MicroagentsModalHeader
<SkillsModalHeader
isAgentReady={isAgentReady}
isLoading={isLoading}
isRefetching={isRefetching}
@ -68,7 +57,7 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
{isAgentReady && (
<Typography.Text className="text-sm text-gray-400">
{t(I18nKey.MICROAGENTS_MODAL$WARNING)}
{t(I18nKey.SKILLS_MODAL$WARNING)}
</Typography.Text>
)}
@ -81,26 +70,23 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
</div>
)}
{isLoading && <MicroagentsLoadingState />}
{isLoading && <SkillsLoadingState />}
{!isLoading &&
isAgentReady &&
(isError || !microagents || microagents.length === 0) && (
<MicroagentsEmptyState isError={isError} />
(isError || !skills || skills.length === 0) && (
<SkillsEmptyState isError={isError} />
)}
{!isLoading &&
isAgentReady &&
microagents &&
microagents.length > 0 && (
{!isLoading && isAgentReady && skills && skills.length > 0 && (
<div className="p-2 space-y-3">
{microagents.map((agent) => {
const isExpanded = expandedAgents[agent.name] || false;
{skills.map((skill) => {
const isExpanded = expandedAgents[skill.name] || false;
return (
<MicroagentItem
key={agent.name}
agent={agent}
<SkillItem
key={skill.name}
skill={skill}
isExpanded={isExpanded}
onToggle={toggleAgent}
/>

View File

@ -31,7 +31,7 @@ interface ConversationNameContextMenuProps {
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowMicroagents?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
@ -44,7 +44,7 @@ export function ConversationNameContextMenu({
onStop,
onDisplayCost,
onShowAgentTools,
onShowMicroagents,
onShowSkills,
onExportConversation,
onDownloadViaVSCode,
position = "bottom",
@ -55,13 +55,12 @@ export function ConversationNameContextMenu({
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { data: conversation } = useActiveConversation();
// TODO: Hide microagent menu items for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
const hasDownload = Boolean(onDownloadViaVSCode);
const hasExport = Boolean(onExportConversation);
const hasTools = Boolean(onShowAgentTools || onShowMicroagents);
const hasTools = Boolean(onShowAgentTools || onShowSkills);
const hasInfo = Boolean(onDisplayCost);
const hasControl = Boolean(onStop || onDelete);
@ -91,15 +90,15 @@ export function ConversationNameContextMenu({
{hasTools && <Divider testId="separator-tools" />}
{onShowMicroagents && !isV1Conversation && (
{onShowSkills && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
testId="show-skills-button"
onClick={onShowSkills}
className={contextMenuListItemClassName}
>
<ConversationNameContextMenuIconText
icon={<RobotIcon width={16} height={16} />}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>

View File

@ -9,7 +9,7 @@ import { I18nKey } from "#/i18n/declaration";
import { EllipsisButton } from "../conversation-panel/ellipsis-button";
import { ConversationNameContextMenu } from "./conversation-name-context-menu";
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
import { MicroagentsModal } from "../conversation-panel/microagents-modal";
import { SkillsModal } from "../conversation-panel/skills-modal";
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
import { MetricsModal } from "./metrics-modal/metrics-modal";
@ -32,7 +32,7 @@ export function ConversationName() {
handleDownloadViaVSCode,
handleDisplayCost,
handleShowAgentTools,
handleShowMicroagents,
handleShowSkills,
handleExportConversation,
handleConfirmDelete,
handleConfirmStop,
@ -40,8 +40,8 @@ export function ConversationName() {
setMetricsModalVisible,
systemModalVisible,
setSystemModalVisible,
microagentsModalVisible,
setMicroagentsModalVisible,
skillsModalVisible,
setSkillsModalVisible,
confirmDeleteModalVisible,
setConfirmDeleteModalVisible,
confirmStopModalVisible,
@ -52,7 +52,7 @@ export function ConversationName() {
shouldShowExport,
shouldShowDisplayCost,
shouldShowAgentTools,
shouldShowMicroagents,
shouldShowSkills,
} = useConversationNameContextMenu({
conversationId,
conversationStatus: conversation?.status,
@ -170,9 +170,7 @@ export function ConversationName() {
onShowAgentTools={
shouldShowAgentTools ? handleShowAgentTools : undefined
}
onShowMicroagents={
shouldShowMicroagents ? handleShowMicroagents : undefined
}
onShowSkills={shouldShowSkills ? handleShowSkills : undefined}
onExportConversation={
shouldShowExport ? handleExportConversation : undefined
}
@ -199,9 +197,9 @@ export function ConversationName() {
systemMessage={systemMessage ? systemMessage.args : null}
/>
{/* Microagents Modal */}
{microagentsModalVisible && (
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
{/* Skills Modal */}
{skillsModalVisible && (
<SkillsModal onClose={() => setSkillsModalVisible(false)} />
)}
{/* Confirm Delete Modal */}

View File

@ -1,19 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useConversationId } from "../use-conversation-id";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import { useSettings } from "./use-settings";
export const useConversationMicroagents = () => {
export const useConversationSkills = () => {
const { conversationId } = useConversationId();
const { curAgentState } = useAgentState();
const { data: settings } = useSettings();
return useQuery({
queryKey: ["conversation", conversationId, "microagents"],
queryKey: ["conversation", conversationId, "skills", settings?.v1_enabled],
queryFn: async () => {
if (!conversationId) {
throw new Error("No conversation ID provided");
}
// Check if V1 is enabled and use the appropriate API
if (settings?.v1_enabled) {
const data = await V1ConversationService.getSkills(conversationId);
return data.skills;
}
const data = await ConversationService.getMicroagents(conversationId);
return data.microagents;
},

View File

@ -41,8 +41,7 @@ export function useConversationNameContextMenu({
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
const [microagentsModalVisible, setMicroagentsModalVisible] =
React.useState(false);
const [skillsModalVisible, setSkillsModalVisible] = React.useState(false);
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
@ -161,11 +160,9 @@ export function useConversationNameContextMenu({
onContextMenuToggle?.(false);
};
const handleShowMicroagents = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
const handleShowSkills = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setMicroagentsModalVisible(true);
setSkillsModalVisible(true);
onContextMenuToggle?.(false);
};
@ -178,7 +175,7 @@ export function useConversationNameContextMenu({
handleDownloadViaVSCode,
handleDisplayCost,
handleShowAgentTools,
handleShowMicroagents,
handleShowSkills,
handleConfirmDelete,
handleConfirmStop,
@ -187,8 +184,8 @@ export function useConversationNameContextMenu({
setMetricsModalVisible,
systemModalVisible,
setSystemModalVisible,
microagentsModalVisible,
setMicroagentsModalVisible,
skillsModalVisible,
setSkillsModalVisible,
confirmDeleteModalVisible,
setConfirmDeleteModalVisible,
confirmStopModalVisible,
@ -204,6 +201,6 @@ export function useConversationNameContextMenu({
shouldShowExport: Boolean(conversationId && showOptions),
shouldShowDisplayCost: showOptions,
shouldShowAgentTools: Boolean(showOptions && systemMessage),
shouldShowMicroagents: Boolean(showOptions && conversationId),
shouldShowSkills: Boolean(showOptions && conversationId),
};
}

View File

@ -640,17 +640,16 @@ export enum I18nKey {
TOS$CONTINUE = "TOS$CONTINUE",
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT",
CONVERSATION$SHOW_MICROAGENTS = "CONVERSATION$SHOW_MICROAGENTS",
CONVERSATION$NO_MICROAGENTS = "CONVERSATION$NO_MICROAGENTS",
CONVERSATION$NO_SKILLS = "CONVERSATION$NO_SKILLS",
CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS",
MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE",
MICROAGENTS_MODAL$WARNING = "MICROAGENTS_MODAL$WARNING",
MICROAGENTS_MODAL$TRIGGERS = "MICROAGENTS_MODAL$TRIGGERS",
SKILLS_MODAL$WARNING = "SKILLS_MODAL$WARNING",
COMMON$TRIGGERS = "COMMON$TRIGGERS",
MICROAGENTS_MODAL$INPUTS = "MICROAGENTS_MODAL$INPUTS",
MICROAGENTS_MODAL$TOOLS = "MICROAGENTS_MODAL$TOOLS",
MICROAGENTS_MODAL$CONTENT = "MICROAGENTS_MODAL$CONTENT",
MICROAGENTS_MODAL$NO_CONTENT = "MICROAGENTS_MODAL$NO_CONTENT",
MICROAGENTS_MODAL$FETCH_ERROR = "MICROAGENTS_MODAL$FETCH_ERROR",
COMMON$CONTENT = "COMMON$CONTENT",
SKILLS_MODAL$NO_CONTENT = "SKILLS_MODAL$NO_CONTENT",
COMMON$FETCH_ERROR = "COMMON$FETCH_ERROR",
TIPS$SETUP_SCRIPT = "TIPS$SETUP_SCRIPT",
TIPS$VSCODE_INSTANCE = "TIPS$VSCODE_INSTANCE",
TIPS$SAVE_WORK = "TIPS$SAVE_WORK",
@ -957,4 +956,6 @@ export enum I18nKey {
COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION",
PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED",
OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY",
CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE",
}

View File

@ -10239,37 +10239,21 @@
"tr": "Kullanılabilir bir mikro ajan kullanarak OpenHands'i deponuz için özelleştirebilirsiniz. OpenHands'ten deponun açıklamasını, kodun nasıl çalıştırılacağı dahil, .openhands/microagents/repo.md dosyasına koymasını isteyin.",
"uk": "Ви можете налаштувати OpenHands для свого репозиторію за допомогою доступного мікроагента. Попросіть OpenHands розмістити опис репозиторію, включаючи інформацію про те, як запустити код, у файлі .openhands/microagents/repo.md."
},
"CONVERSATION$SHOW_MICROAGENTS": {
"en": "Show Available Microagents",
"ja": "利用可能なマイクロエージェントを表示",
"zh-CN": "显示可用微代理",
"zh-TW": "顯示可用微代理",
"ko-KR": "사용 가능한 마이크로에이전트 표시",
"no": "Vis tilgjengelige mikroagenter",
"ar": "عرض الوكلاء المصغرين المتاحة",
"de": "Verfügbare Mikroagenten anzeigen",
"fr": "Afficher les micro-agents disponibles",
"it": "Mostra microagenti disponibili",
"pt": "Mostrar microagentes disponíveis",
"es": "Mostrar microagentes disponibles",
"tr": "Kullanılabilir mikro ajanları göster",
"uk": "Показати доступних мікроагентів"
},
"CONVERSATION$NO_MICROAGENTS": {
"en": "No available microagents found for this conversation.",
"ja": "この会話用の利用可能なマイクロエージェントが見つかりませんでした。",
"zh-CN": "未找到此对话的可用微代理。",
"zh-TW": "未找到此對話的可用微代理。",
"ko-KR": "이 대화에 대한 사용 가능한 마이크로에이전트를 찾을 수 없습니다.",
"no": "Ingen tilgjengelige mikroagenter funnet for denne samtalen.",
"ar": "لم يتم العثور على وكلاء مصغرين متاحة لهذه المحادثة.",
"de": "Keine verfügbaren Mikroagenten für dieses Gespräch gefunden.",
"fr": "Aucun micro-agent disponible trouvé pour cette conversation.",
"it": "Nessun microagente disponibile trovato per questa conversazione.",
"pt": "Nenhum microagente disponível encontrado para esta conversa.",
"es": "No se encontraron microagentes disponibles para esta conversación.",
"tr": "Bu konuşma için kullanılabilir mikro ajan bulunamadı.",
"uk": "Для цієї розмови не знайдено доступних мікроагентів."
"CONVERSATION$NO_SKILLS": {
"en": "No available skills found for this conversation.",
"ja": "この会話には利用可能なスキルが見つかりません。",
"zh-CN": "本会话未找到可用技能。",
"zh-TW": "此對話中未找到可用技能。",
"ko-KR": "이 대화에서 사용 가능한 스킬을 찾을 수 없습니다.",
"no": "Ingen tilgjengelige ferdigheter ble funnet for denne samtalen.",
"ar": "لم يتم العثور على مهارات متاحة لهذه المحادثة.",
"de": "Für diese Unterhaltung wurden keine verfügbaren Skills gefunden.",
"fr": "Aucune compétence disponible trouvée pour cette conversation.",
"it": "Nessuna abilità disponibile trovata per questa conversazione.",
"pt": "Nenhuma habilidade disponível encontrada para esta conversa.",
"es": "No se encontraron habilidades disponibles para esta conversación.",
"tr": "Bu sohbet için kullanılabilir yetenek bulunamadı.",
"uk": "У цій розмові не знайдено доступних навичок."
},
"CONVERSATION$FAILED_TO_FETCH_MICROAGENTS": {
"en": "Failed to fetch available microagents",
@ -10303,23 +10287,23 @@
"tr": "Kullanılabilir mikro ajanlar",
"uk": "Доступні мікроагенти"
},
"MICROAGENTS_MODAL$WARNING": {
"en": "If you update the microagents, you will need to stop the conversation and then click on the refresh button to see the changes.",
"ja": "マイクロエージェントを更新する場合、会話を停止してから更新ボタンをクリックして変更を確認する必要があります。",
"zh-CN": "如果您更新微代理,您需要停止对话,然后点击刷新按钮以查看更改。",
"zh-TW": "如果您更新微代理,您需要停止對話,然後點擊重新整理按鈕以查看更改。",
"ko-KR": "마이크로에이전트를 업데이트하는 경우 대화를 중지한 후 새로고침 버튼을 클릭하여 변경사항을 확인해야 합니다.",
"no": "Hvis du oppdaterer mikroagentene, må du stoppe samtalen og deretter klikke på oppdater-knappen for å se endringene.",
"ar": "إذا قمت بتحديث الوكلاء المصغرين، فستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.",
"de": "Wenn Sie die Mikroagenten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Aktualisieren-Schaltfläche klicken, um die Änderungen zu sehen.",
"fr": "Si vous mettez à jour les micro-agents, vous devrez arrêter la conversation puis cliquer sur le bouton actualiser pour voir les changements.",
"it": "Se aggiorni i microagenti, dovrai fermare la conversazione e poi cliccare sul pulsante aggiorna per vedere le modifiche.",
"pt": "Se você atualizar os microagentes, precisará parar a conversa e depois clicar no botão atualizar para ver as alterações.",
"es": "Si actualiza los microagentes, necesitará detener la conversación y luego hacer clic en el botón actualizar para ver los cambios.",
"tr": "Mikro ajanları güncellerseniz, konuşmayı durdurmanız ve ardından değişiklikleri görmek için yenile düğmesine tıklamanız gerekecektir.",
"uk": "Якщо ви оновите мікроагенти, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни."
"SKILLS_MODAL$WARNING": {
"en": "If you update the skills, you will need to stop the conversation and then click on the refresh button to see the changes.",
"ja": "スキルを更新する場合、会話を停止し、その後、更新ボタンをクリックして変更を反映させる必要があります。",
"zh-CN": "如果您更新技能,需要先停止对话,然后点击刷新按钮以查看更改。",
"zh-TW": "如果您更新技能,需要先停止對話,然後點擊刷新按鈕以查看更改。",
"ko-KR": "스킬을 업데이트하면 대화를 중단한 후 새로 고침 버튼을 클릭해야 변경 사항을 볼 수 있습니다.",
"no": "Hvis du oppdaterer ferdighetene, må du stoppe samtalen og deretter klikke på oppdateringsknappen for å se endringene.",
"ar": "إذا قمت بتحديث المهارات، ستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.",
"de": "Wenn Sie die Fähigkeiten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Schaltfläche 'Aktualisieren' klicken, um die Änderungen zu sehen.",
"fr": "Si vous mettez à jour les compétences, vous devrez arrêter la conversation, puis cliquer sur le bouton dactualisation pour voir les modifications.",
"it": "Se aggiorni le competenze, dovrai interrompere la conversazione e poi cliccare sul pulsante di aggiornamento per vedere le modifiche.",
"pt": "Se você atualizar as habilidades, precisará interromper a conversa e clicar no botão de atualizar para ver as mudanças.",
"es": "Si actualizas las habilidades, deberás detener la conversación y luego hacer clic en el botón de actualizar para ver los cambios.",
"tr": "Yetenekleri güncellerseniz, değişiklikleri görmek için sohbeti durdurmalı ve ardından yenile düğmesine tıklamalısınız.",
"uk": "Якщо ви оновите навички, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни."
},
"MICROAGENTS_MODAL$TRIGGERS": {
"COMMON$TRIGGERS": {
"en": "Triggers",
"ja": "トリガー",
"zh-CN": "触发器",
@ -10367,7 +10351,7 @@
"tr": "Araçlar",
"uk": "Інструменти"
},
"MICROAGENTS_MODAL$CONTENT": {
"COMMON$CONTENT": {
"en": "Content",
"ja": "コンテンツ",
"zh-CN": "内容",
@ -10383,37 +10367,37 @@
"tr": "İçerik",
"uk": "Вміст"
},
"MICROAGENTS_MODAL$NO_CONTENT": {
"en": "Microagent has no content",
"ja": "マイクロエージェントにコンテンツがありません",
"zh-CN": "微代理没有内容",
"zh-TW": "微代理沒有內容",
"ko-KR": "마이크로에이전트에 콘텐츠가 없습니다",
"no": "Mikroagenten har ikke innhold",
"ar": "الوكيل المصغر ليس لديه محتوى",
"de": "Mikroagent hat keinen Inhalt",
"fr": "Le micro-agent n'a pas de contenu",
"it": "Il microagente non ha contenuto",
"pt": "Microagente não tem conteúdo",
"es": "El microagente no tiene contenido",
"tr": "Mikroajanın içeriği yok",
"uk": "Мікроагент не має вмісту"
"SKILLS_MODAL$NO_CONTENT": {
"en": "Skill has no content",
"ja": "スキルにはコンテンツがありません",
"zh-CN": "技能没有内容",
"zh-TW": "技能沒有內容",
"ko-KR": "스킬에 컨텐츠가 없습니다",
"no": "Ferdighet har ikke noe innhold",
"ar": "المهارة ليس لديها محتوى",
"de": "Die Fähigkeit hat keinen Inhalt",
"fr": "La compétence n'a pas de contenu",
"it": "La competenza non ha contenuti",
"pt": "A habilidade não possui conteúdo",
"es": "La habilidad no tiene contenido",
"tr": "Beceride içerik yok",
"uk": "У навички немає вмісту"
},
"MICROAGENTS_MODAL$FETCH_ERROR": {
"en": "Failed to fetch microagents. Please try again later.",
"ja": "マイクロエージェントの取得に失敗しました。後でもう一度お試しください。",
"zh-CN": "获取微代理失败。请稍后再试。",
"zh-TW": "獲取微代理失敗。請稍後再試。",
"ko-KR": "마이크로에이전트를 가져오지 못했습니다. 나중에 다시 시도해 주세요.",
"no": "Kunne ikke hente mikroagenter. Prøv igjen senere.",
"ar": "فشل في جلب الوكلاء المصغرين. يرجى المحاولة مرة أخرى لاحقًا.",
"de": "Mikroagenten konnten nicht abgerufen werden. Bitte versuchen Sie es später erneut.",
"fr": "Échec de la récupération des micro-agents. Veuillez réessayer plus tard.",
"it": "Impossibile recuperare i microagenti. Riprova più tardi.",
"pt": "Falha ao buscar microagentes. Por favor, tente novamente mais tarde.",
"es": "Error al obtener microagentes. Por favor, inténtelo de nuevo más tarde.",
"tr": "Mikroajanlar getirilemedi. Lütfen daha sonra tekrar deneyin.",
"uk": "Не вдалося отримати мікроагентів. Будь ласка, спробуйте пізніше."
"COMMON$FETCH_ERROR": {
"en": "Failed to fetch skills. Please try again later.",
"ja": "スキルの取得に失敗しました。後でもう一度お試しください。",
"zh-CN": "获取技能失败。请稍后再试。",
"zh-TW": "取得技能失敗。請稍後再試。",
"ko-KR": "스킬을 가져오지 못했습니다. 나중에 다시 시도해주세요.",
"no": "Kunne ikke hente ferdigheter. Prøv igjen senere.",
"ar": "فشل في جلب المهارات. يرجى المحاولة لاحقًا.",
"de": "Die Fähigkeiten konnten nicht abgerufen werden. Bitte versuchen Sie es später erneut.",
"fr": "Échec de la récupération des compétences. Veuillez réessayer plus tard.",
"it": "Impossibile recuperare le competenze. Riprova più tardi.",
"pt": "Falha ao buscar as habilidades. Por favor, tente novamente mais tarde.",
"es": "No se pudieron obtener las habilidades. Por favor, inténtalo de nuevo más tarde.",
"tr": "Beceriler alınamadı. Lütfen daha sonra tekrar deneyin.",
"uk": "Не вдалося отримати навички. Будь ласка, спробуйте пізніше."
},
"TIPS$SETUP_SCRIPT": {
"en": "You can add .openhands/setup.sh to your repository to automatically run a setup script every time you start an OpenHands conversation.",
@ -15310,5 +15294,37 @@
"tr": "Yetenek hazır",
"de": "Fähigkeit bereit",
"uk": "Навичка готова"
},
"CONVERSATION$SHOW_SKILLS": {
"en": "Show Available Skills",
"ja": "利用可能なスキルを表示",
"zh-CN": "显示可用技能",
"zh-TW": "顯示可用技能",
"ko-KR": "사용 가능한 스킬 표시",
"no": "Vis tilgjengelige ferdigheter",
"ar": "عرض المهارات المتاحة",
"de": "Verfügbare Fähigkeiten anzeigen",
"fr": "Afficher les compétences disponibles",
"it": "Mostra abilità disponibili",
"pt": "Mostrar habilidades disponíveis",
"es": "Mostrar habilidades disponibles",
"tr": "Kullanılabilir yetenekleri göster",
"uk": "Показати доступні навички"
},
"SKILLS_MODAL$TITLE": {
"en": "Available Skills",
"ja": "利用可能なスキル",
"zh-CN": "可用技能",
"zh-TW": "可用技能",
"ko-KR": "사용 가능한 스킬",
"no": "Tilgjengelige ferdigheter",
"ar": "المهارات المتاحة",
"de": "Verfügbare Fähigkeiten",
"fr": "Compétences disponibles",
"it": "Abilità disponibili",
"pt": "Habilidades disponíveis",
"es": "Habilidades disponibles",
"tr": "Kullanılabilir yetenekler",
"uk": "Доступні навички"
}
}

View File

@ -1,5 +1,6 @@
from datetime import datetime
from enum import Enum
from typing import Literal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
@ -161,3 +162,12 @@ class AppConversationStartTask(BaseModel):
class AppConversationStartTaskPage(BaseModel):
items: list[AppConversationStartTask]
next_page_id: str | None = None
class SkillResponse(BaseModel):
"""Response model for skills endpoint."""
name: str
type: Literal['repo', 'knowledge']
content: str
triggers: list[str] = []

View File

@ -1,11 +1,12 @@
"""Sandboxed Conversation router for OpenHands Server."""
import asyncio
import logging
import os
import sys
import tempfile
from datetime import datetime
from typing import Annotated, AsyncGenerator
from typing import Annotated, AsyncGenerator, Literal
from uuid import UUID
import httpx
@ -28,8 +29,8 @@ else:
return await async_iterator.__anext__()
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Query, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.app_server.app_conversation.app_conversation_models import (
@ -39,10 +40,14 @@ from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTask,
AppConversationStartTaskPage,
AppConversationStartTaskSortOrder,
SkillResponse,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
)
@ -65,9 +70,11 @@ from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
from openhands.app_server.utils.docker_utils import (
replace_localhost_hostname_for_docker,
)
from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
router = APIRouter(prefix='/app-conversations', tags=['Conversations'])
logger = logging.getLogger(__name__)
app_conversation_service_dependency = depends_app_conversation_service()
app_conversation_start_task_service_dependency = (
depends_app_conversation_start_task_service()
@ -400,6 +407,145 @@ async def read_conversation_file(
return ''
@router.get('/{conversation_id}/skills')
async def get_conversation_skills(
conversation_id: UUID,
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
sandbox_service: SandboxService = sandbox_service_dependency,
sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency,
) -> JSONResponse:
"""Get all skills associated with the conversation.
This endpoint returns all skills that are loaded for the v1 conversation.
Skills are loaded from multiple sources:
- Sandbox skills (exposed URLs)
- Global skills (OpenHands/skills/)
- User skills (~/.openhands/skills/)
- Organization skills (org/.openhands repository)
- Repository skills (repo/.openhands/skills/ or .openhands/microagents/)
Returns:
JSONResponse: A JSON response containing the list of skills.
"""
try:
# Get the conversation info
conversation = await app_conversation_service.get_app_conversation(
conversation_id
)
if not conversation:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': f'Conversation {conversation_id} not found'},
)
# Get the sandbox info
sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
if not sandbox or sandbox.status != SandboxStatus.RUNNING:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={
'error': f'Sandbox not found or not running for conversation {conversation_id}'
},
)
# Get the sandbox spec to find the working directory
sandbox_spec = await sandbox_spec_service.get_sandbox_spec(
sandbox.sandbox_spec_id
)
if not sandbox_spec:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Sandbox spec not found'},
)
# Get the agent server URL
if not sandbox.exposed_urls:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'No agent server URL found for sandbox'},
)
agent_server_url = None
for exposed_url in sandbox.exposed_urls:
if exposed_url.name == AGENT_SERVER:
agent_server_url = exposed_url.url
break
if not agent_server_url:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Agent server URL not found in sandbox'},
)
agent_server_url = replace_localhost_hostname_for_docker(agent_server_url)
# Create remote workspace
remote_workspace = AsyncRemoteWorkspace(
host=agent_server_url,
api_key=sandbox.session_api_key,
working_dir=sandbox_spec.working_dir,
)
# Load skills from all sources
logger.info(f'Loading skills for conversation {conversation_id}')
# Prefer the shared loader to avoid duplication; otherwise return empty list.
all_skills: list = []
if isinstance(app_conversation_service, AppConversationServiceBase):
all_skills = await app_conversation_service.load_and_merge_all_skills(
sandbox,
remote_workspace,
conversation.selected_repository,
sandbox_spec.working_dir,
)
logger.info(
f'Loaded {len(all_skills)} skills for conversation {conversation_id}: '
f'{[s.name for s in all_skills]}'
)
# Transform skills to response format
skills_response = []
for skill in all_skills:
# Determine type based on trigger
skill_type: Literal['repo', 'knowledge']
if skill.trigger is None:
skill_type = 'repo'
else:
skill_type = 'knowledge'
# Extract triggers
triggers = []
if isinstance(skill.trigger, (KeywordTrigger, TaskTrigger)):
if hasattr(skill.trigger, 'keywords'):
triggers = skill.trigger.keywords
elif hasattr(skill.trigger, 'triggers'):
triggers = skill.trigger.triggers
skills_response.append(
SkillResponse(
name=skill.name,
type=skill_type,
content=skill.content,
triggers=triggers,
)
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'skills': [s.model_dump() for s in skills_response]},
)
except Exception as e:
logger.error(f'Error getting skills for conversation {conversation_id}: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': f'Error getting skills: {str(e)}'},
)
async def _consume_remaining(
async_iter, db_session: AsyncSession, httpx_client: httpx.AsyncClient
):

View File

@ -58,7 +58,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
init_git_in_empty_workspace: bool
user_context: UserContext
async def _load_and_merge_all_skills(
async def load_and_merge_all_skills(
self,
sandbox: SandboxInfo,
remote_workspace: AsyncRemoteWorkspace,
@ -169,7 +169,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
Updated agent with skills loaded into context
"""
# Load and merge all skills
all_skills = await self._load_and_merge_all_skills(
all_skills = await self.load_and_merge_all_skills(
sandbox, remote_workspace, selected_repository, working_dir
)
@ -198,7 +198,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
yield task
await self._load_and_merge_all_skills(
await self.load_and_merge_all_skills(
sandbox,
workspace,
task.request.selected_repository,

View File

@ -920,12 +920,12 @@ async def test_configure_git_user_settings_special_characters_in_name(mock_works
# =============================================================================
# Tests for _load_and_merge_all_skills with org skills
# Tests for load_and_merge_all_skills with org skills
# =============================================================================
class TestLoadAndMergeAllSkillsWithOrgSkills:
"""Test _load_and_merge_all_skills includes organization skills."""
"""Test load_and_merge_all_skills includes organization skills."""
@pytest.mark.asyncio
@patch(
@ -951,7 +951,7 @@ class TestLoadAndMergeAllSkillsWithOrgSkills:
mock_load_global,
mock_load_sandbox,
):
"""Test that _load_and_merge_all_skills loads and merges org skills."""
"""Test that load_and_merge_all_skills loads and merges org skills."""
# Arrange
mock_user_context = Mock(spec=UserContext)
with patch.object(
@ -987,7 +987,7 @@ class TestLoadAndMergeAllSkillsWithOrgSkills:
mock_load_repo.return_value = [repo_skill]
# Act
result = await service._load_and_merge_all_skills(
result = await service.load_and_merge_all_skills(
sandbox, remote_workspace, 'owner/repo', '/workspace'
)
@ -1066,7 +1066,7 @@ class TestLoadAndMergeAllSkillsWithOrgSkills:
mock_load_repo.return_value = [repo_skill]
# Act
result = await service._load_and_merge_all_skills(
result = await service.load_and_merge_all_skills(
sandbox, remote_workspace, 'owner/repo', '/workspace'
)
@ -1132,7 +1132,7 @@ class TestLoadAndMergeAllSkillsWithOrgSkills:
mock_load_repo.return_value = []
# Act
result = await service._load_and_merge_all_skills(
result = await service.load_and_merge_all_skills(
sandbox, remote_workspace, 'owner/repo', '/workspace'
)
@ -1193,7 +1193,7 @@ class TestLoadAndMergeAllSkillsWithOrgSkills:
mock_load_repo.return_value = [repo_skill]
# Act
result = await service._load_and_merge_all_skills(
result = await service.load_and_merge_all_skills(
sandbox, remote_workspace, 'owner/repo', '/workspace'
)
@ -1254,7 +1254,7 @@ class TestLoadAndMergeAllSkillsWithOrgSkills:
mock_load_repo.return_value = []
# Act
result = await service._load_and_merge_all_skills(
result = await service.load_and_merge_all_skills(
sandbox, remote_workspace, None, '/workspace'
)

View File

@ -0,0 +1,503 @@
"""Unit tests for the V1 skills endpoint in app_conversation_router.
This module tests the GET /{conversation_id}/skills endpoint functionality,
following TDD best practices with AAA structure.
"""
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import status
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
)
from openhands.app_server.app_conversation.app_conversation_router import (
get_conversation_skills,
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
)
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
ExposedUrl,
SandboxInfo,
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
from openhands.app_server.user.user_context import UserContext
from openhands.sdk.context.skills import KeywordTrigger, Skill, TaskTrigger
def _make_service_mock(
*,
user_context: UserContext,
conversation_return: AppConversation | None = None,
skills_return: list[Skill] | None = None,
raise_on_load: bool = False,
):
"""Create a mock service that passes the isinstance check and returns the desired values."""
mock_cls = type('AppConversationServiceMock', (MagicMock,), {})
AppConversationServiceBase.register(mock_cls)
service = mock_cls()
service.user_context = user_context
service.get_app_conversation = AsyncMock(return_value=conversation_return)
async def _load_skills(*_args, **_kwargs):
if raise_on_load:
raise Exception('Skill loading failed')
return skills_return or []
service.load_and_merge_all_skills = AsyncMock(side_effect=_load_skills)
return service
@pytest.mark.asyncio
class TestGetConversationSkills:
"""Test suite for get_conversation_skills endpoint."""
async def test_get_skills_returns_repo_and_knowledge_skills(self):
"""Test successful retrieval of both repo and knowledge skills.
Arrange: Setup conversation, sandbox, and skills with different types
Act: Call get_conversation_skills endpoint
Assert: Response contains both repo and knowledge skills with correct types
"""
# Arrange
conversation_id = uuid4()
sandbox_id = str(uuid4())
working_dir = '/workspace'
# Create mock conversation
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
selected_repository='owner/repo',
sandbox_status=SandboxStatus.RUNNING,
)
# Create mock sandbox with agent server URL
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.RUNNING,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
],
)
# Create mock sandbox spec
mock_sandbox_spec = SandboxSpecInfo(
id=str(uuid4()), command=None, working_dir=working_dir
)
# Create mock skills - repo skill (no trigger)
repo_skill = Skill(
name='repo_skill',
content='Repository skill content',
trigger=None,
)
# Create mock skills - knowledge skill (with KeywordTrigger)
knowledge_skill = Skill(
name='knowledge_skill',
content='Knowledge skill content',
trigger=KeywordTrigger(keywords=['test', 'help']),
)
# Mock services
mock_user_context = MagicMock(spec=UserContext)
mock_app_conversation_service = _make_service_mock(
user_context=mock_user_context,
conversation_return=mock_conversation,
skills_return=[repo_skill, knowledge_skill],
)
mock_sandbox_service = MagicMock()
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
mock_sandbox_spec_service = MagicMock()
mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
return_value=mock_sandbox_spec
)
# Act
response = await get_conversation_skills(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
)
# Assert
assert response.status_code == status.HTTP_200_OK
content = response.body.decode('utf-8')
import json
data = json.loads(content)
assert 'skills' in data
assert len(data['skills']) == 2
# Check repo skill
repo_skill_data = next(
(s for s in data['skills'] if s['name'] == 'repo_skill'), None
)
assert repo_skill_data is not None
assert repo_skill_data['type'] == 'repo'
assert repo_skill_data['content'] == 'Repository skill content'
assert repo_skill_data['triggers'] == []
# Check knowledge skill
knowledge_skill_data = next(
(s for s in data['skills'] if s['name'] == 'knowledge_skill'), None
)
assert knowledge_skill_data is not None
assert knowledge_skill_data['type'] == 'knowledge'
assert knowledge_skill_data['content'] == 'Knowledge skill content'
assert knowledge_skill_data['triggers'] == ['test', 'help']
async def test_get_skills_returns_404_when_conversation_not_found(self):
"""Test endpoint returns 404 when conversation doesn't exist.
Arrange: Setup mocks to return None for conversation
Act: Call get_conversation_skills endpoint
Assert: Response is 404 with appropriate error message
"""
# Arrange
conversation_id = uuid4()
mock_user_context = MagicMock(spec=UserContext)
mock_app_conversation_service = _make_service_mock(
user_context=mock_user_context,
conversation_return=None,
)
mock_sandbox_service = MagicMock()
mock_sandbox_spec_service = MagicMock()
# Act
response = await get_conversation_skills(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
content = response.body.decode('utf-8')
import json
data = json.loads(content)
assert 'error' in data
assert str(conversation_id) in data['error']
async def test_get_skills_returns_404_when_sandbox_not_found(self):
"""Test endpoint returns 404 when sandbox doesn't exist.
Arrange: Setup conversation but no sandbox
Act: Call get_conversation_skills endpoint
Assert: Response is 404 with sandbox error message
"""
# Arrange
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
sandbox_status=SandboxStatus.RUNNING,
)
mock_user_context = MagicMock(spec=UserContext)
mock_app_conversation_service = _make_service_mock(
user_context=mock_user_context,
conversation_return=mock_conversation,
)
mock_sandbox_service = MagicMock()
mock_sandbox_service.get_sandbox = AsyncMock(return_value=None)
mock_sandbox_spec_service = MagicMock()
# Act
response = await get_conversation_skills(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
content = response.body.decode('utf-8')
import json
data = json.loads(content)
assert 'error' in data
assert 'Sandbox not found' in data['error']
async def test_get_skills_returns_404_when_sandbox_not_running(self):
"""Test endpoint returns 404 when sandbox is not in RUNNING state.
Arrange: Setup conversation with stopped sandbox
Act: Call get_conversation_skills endpoint
Assert: Response is 404 with sandbox not running message
"""
# Arrange
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
sandbox_status=SandboxStatus.PAUSED,
)
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.PAUSED,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
)
mock_user_context = MagicMock(spec=UserContext)
mock_app_conversation_service = _make_service_mock(
user_context=mock_user_context,
conversation_return=mock_conversation,
)
mock_sandbox_service = MagicMock()
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
mock_sandbox_spec_service = MagicMock()
# Act
response = await get_conversation_skills(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
content = response.body.decode('utf-8')
import json
data = json.loads(content)
assert 'error' in data
assert 'not running' in data['error']
async def test_get_skills_handles_task_trigger_skills(self):
"""Test endpoint correctly handles skills with TaskTrigger.
Arrange: Setup skill with TaskTrigger
Act: Call get_conversation_skills endpoint
Assert: Skill is categorized as knowledge type with correct triggers
"""
# Arrange
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
sandbox_status=SandboxStatus.RUNNING,
)
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.RUNNING,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
],
)
mock_sandbox_spec = SandboxSpecInfo(
id=str(uuid4()), command=None, working_dir='/workspace'
)
# Create task skill with TaskTrigger
task_skill = Skill(
name='task_skill',
content='Task skill content',
trigger=TaskTrigger(triggers=['task', 'execute']),
)
mock_user_context = MagicMock(spec=UserContext)
mock_app_conversation_service = _make_service_mock(
user_context=mock_user_context,
conversation_return=mock_conversation,
skills_return=[task_skill],
)
mock_sandbox_service = MagicMock()
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
mock_sandbox_spec_service = MagicMock()
mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
return_value=mock_sandbox_spec
)
# Act
response = await get_conversation_skills(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
)
# Assert
assert response.status_code == status.HTTP_200_OK
content = response.body.decode('utf-8')
import json
data = json.loads(content)
assert len(data['skills']) == 1
skill_data = data['skills'][0]
assert skill_data['type'] == 'knowledge'
assert skill_data['triggers'] == ['task', 'execute']
async def test_get_skills_returns_500_on_skill_loading_error(self):
"""Test endpoint returns 500 when skill loading fails.
Arrange: Setup mocks to raise exception during skill loading
Act: Call get_conversation_skills endpoint
Assert: Response is 500 with error message
"""
# Arrange
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
sandbox_status=SandboxStatus.RUNNING,
)
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.RUNNING,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
],
)
mock_sandbox_spec = SandboxSpecInfo(
id=str(uuid4()), command=None, working_dir='/workspace'
)
mock_user_context = MagicMock(spec=UserContext)
mock_app_conversation_service = _make_service_mock(
user_context=mock_user_context,
conversation_return=mock_conversation,
raise_on_load=True,
)
mock_sandbox_service = MagicMock()
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
mock_sandbox_spec_service = MagicMock()
mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
return_value=mock_sandbox_spec
)
# Act
response = await get_conversation_skills(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
)
# Assert
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
content = response.body.decode('utf-8')
import json
data = json.loads(content)
assert 'error' in data
assert 'Error getting skills' in data['error']
async def test_get_skills_returns_empty_list_when_no_skills_loaded(self):
"""Test endpoint returns empty skills list when no skills are found.
Arrange: Setup all skill loaders to return empty lists
Act: Call get_conversation_skills endpoint
Assert: Response contains empty skills array
"""
# Arrange
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
sandbox_status=SandboxStatus.RUNNING,
)
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.RUNNING,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
],
)
mock_sandbox_spec = SandboxSpecInfo(
id=str(uuid4()), command=None, working_dir='/workspace'
)
mock_user_context = MagicMock(spec=UserContext)
mock_app_conversation_service = _make_service_mock(
user_context=mock_user_context,
conversation_return=mock_conversation,
skills_return=[],
)
mock_sandbox_service = MagicMock()
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
mock_sandbox_spec_service = MagicMock()
mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
return_value=mock_sandbox_spec
)
# Act
response = await get_conversation_skills(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
)
# Assert
assert response.status_code == status.HTTP_200_OK
content = response.body.decode('utf-8')
import json
data = json.loads(content)
assert 'skills' in data
assert len(data['skills']) == 0