mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
feat: show available skills for v1 conversations (#12039)
This commit is contained in:
parent
f98e7fbc49
commit
9ef11bf930
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
394
frontend/__tests__/components/modals/skills/skill-modal.test.tsx
Normal file
394
frontend/__tests__/components/modals/skills/skill-modal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
@ -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) => (
|
||||
@ -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>
|
||||
);
|
||||
@ -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" />
|
||||
@ -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"
|
||||
@ -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}
|
||||
/>
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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 d’actualisation 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": "Доступні навички"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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] = []
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
)
|
||||
|
||||
|
||||
503
tests/unit/app_server/test_app_conversation_skills_endpoint.py
Normal file
503
tests/unit/app_server/test_app_conversation_skills_endpoint.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user