{triggers.map((trigger) => (
diff --git a/frontend/src/components/features/conversation-panel/microagents-empty-state.tsx b/frontend/src/components/features/conversation-panel/skills-empty-state.tsx
similarity index 63%
rename from frontend/src/components/features/conversation-panel/microagents-empty-state.tsx
rename to frontend/src/components/features/conversation-panel/skills-empty-state.tsx
index 5ef535e178..5a148568a4 100644
--- a/frontend/src/components/features/conversation-panel/microagents-empty-state.tsx
+++ b/frontend/src/components/features/conversation-panel/skills-empty-state.tsx
@@ -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 (
);
diff --git a/frontend/src/components/features/conversation-panel/microagents-loading-state.tsx b/frontend/src/components/features/conversation-panel/skills-loading-state.tsx
similarity index 80%
rename from frontend/src/components/features/conversation-panel/microagents-loading-state.tsx
rename to frontend/src/components/features/conversation-panel/skills-loading-state.tsx
index 9851b82bed..29ea6c9754 100644
--- a/frontend/src/components/features/conversation-panel/microagents-loading-state.tsx
+++ b/frontend/src/components/features/conversation-panel/skills-loading-state.tsx
@@ -1,4 +1,4 @@
-export function MicroagentsLoadingState() {
+export function SkillsLoadingState() {
return (
diff --git a/frontend/src/components/features/conversation-panel/microagents-modal-header.tsx b/frontend/src/components/features/conversation-panel/skills-modal-header.tsx
similarity index 82%
rename from frontend/src/components/features/conversation-panel/microagents-modal-header.tsx
rename to frontend/src/components/features/conversation-panel/skills-modal-header.tsx
index 858f877287..0a0a93fee4 100644
--- a/frontend/src/components/features/conversation-panel/microagents-modal-header.tsx
+++ b/frontend/src/components/features/conversation-panel/skills-modal-header.tsx
@@ -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 (
-
+
{isAgentReady && (
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>(
{},
);
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) {
-
- {t(I18nKey.MICROAGENTS_MODAL$WARNING)}
+ {t(I18nKey.SKILLS_MODAL$WARNING)}
)}
@@ -81,33 +70,30 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
)}
- {isLoading &&
}
+ {isLoading &&
}
{!isLoading &&
isAgentReady &&
- (isError || !microagents || microagents.length === 0) && (
-
+ (isError || !skills || skills.length === 0) && (
+
)}
- {!isLoading &&
- isAgentReady &&
- microagents &&
- microagents.length > 0 && (
-
- {microagents.map((agent) => {
- const isExpanded = expandedAgents[agent.name] || false;
+ {!isLoading && isAgentReady && skills && skills.length > 0 && (
+
+ {skills.map((skill) => {
+ const isExpanded = expandedAgents[skill.name] || false;
- return (
-
- );
- })}
-
- )}
+ return (
+
+ );
+ })}
+
+ )}
diff --git a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx
index 97ade1edb5..95de15b37e 100644
--- a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx
+++ b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx
@@ -31,7 +31,7 @@ interface ConversationNameContextMenuProps {
onStop?: (event: React.MouseEvent
) => void;
onDisplayCost?: (event: React.MouseEvent) => void;
onShowAgentTools?: (event: React.MouseEvent) => void;
- onShowMicroagents?: (event: React.MouseEvent) => void;
+ onShowSkills?: (event: React.MouseEvent) => void;
onExportConversation?: (event: React.MouseEvent) => void;
onDownloadViaVSCode?: (event: React.MouseEvent) => 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(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 && }
- {onShowMicroagents && !isV1Conversation && (
+ {onShowSkills && (
}
- text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
+ text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
diff --git a/frontend/src/components/features/conversation/conversation-name.tsx b/frontend/src/components/features/conversation/conversation-name.tsx
index 2b5c06398c..1dabfe259e 100644
--- a/frontend/src/components/features/conversation/conversation-name.tsx
+++ b/frontend/src/components/features/conversation/conversation-name.tsx
@@ -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 && (
- setMicroagentsModalVisible(false)} />
+ {/* Skills Modal */}
+ {skillsModalVisible && (
+ setSkillsModalVisible(false)} />
)}
{/* Confirm Delete Modal */}
diff --git a/frontend/src/hooks/query/use-conversation-microagents.ts b/frontend/src/hooks/query/use-conversation-skills.ts
similarity index 62%
rename from frontend/src/hooks/query/use-conversation-microagents.ts
rename to frontend/src/hooks/query/use-conversation-skills.ts
index d51b2b311d..43cf23bd37 100644
--- a/frontend/src/hooks/query/use-conversation-microagents.ts
+++ b/frontend/src/hooks/query/use-conversation-skills.ts
@@ -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;
},
diff --git a/frontend/src/hooks/use-conversation-name-context-menu.ts b/frontend/src/hooks/use-conversation-name-context-menu.ts
index 0e2c3e837e..6072d5331e 100644
--- a/frontend/src/hooks/use-conversation-name-context-menu.ts
+++ b/frontend/src/hooks/use-conversation-name-context-menu.ts
@@ -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,
- ) => {
+ const handleShowSkills = (event: React.MouseEvent) => {
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),
};
}
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index f5a6cacfec..2f99b1aef6 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -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",
}
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index 2966c1aa5f..fc4ca89dbc 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -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": "Доступні навички"
}
}
diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py
index 0c7ef99ce5..1c0ba914cb 100644
--- a/openhands/app_server/app_conversation/app_conversation_models.py
+++ b/openhands/app_server/app_conversation/app_conversation_models.py
@@ -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] = []
diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py
index bf82840e96..a7a0414e31 100644
--- a/openhands/app_server/app_conversation/app_conversation_router.py
+++ b/openhands/app_server/app_conversation/app_conversation_router.py
@@ -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
):
diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py
index fb1eb0001e..aa6add73fe 100644
--- a/openhands/app_server/app_conversation/app_conversation_service_base.py
+++ b/openhands/app_server/app_conversation/app_conversation_service_base.py
@@ -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,
diff --git a/tests/unit/app_server/test_app_conversation_service_base.py b/tests/unit/app_server/test_app_conversation_service_base.py
index 01fc63bc5d..db31d8d3d2 100644
--- a/tests/unit/app_server/test_app_conversation_service_base.py
+++ b/tests/unit/app_server/test_app_conversation_service_base.py
@@ -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'
)
diff --git a/tests/unit/app_server/test_app_conversation_skills_endpoint.py b/tests/unit/app_server/test_app_conversation_skills_endpoint.py
new file mode 100644
index 0000000000..e84412bcd0
--- /dev/null
+++ b/tests/unit/app_server/test_app_conversation_skills_endpoint.py
@@ -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