From e9c9374313aee48cfdb531634b3c013d79ce7537 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Sat, 23 May 2026 08:25:57 +0530 Subject: [PATCH] feat(composio): curate OneDrive/Excel/Todoist + UI preview badge for uncurated toolkits (#2283) (#2361) Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: Steven Enamakel --- app/src/lib/composio/composioApi.test.ts | 29 +++ app/src/lib/composio/composioApi.ts | 19 ++ app/src/lib/composio/hooks.test.ts | 65 ++++++ app/src/lib/composio/hooks.ts | 62 +++++- app/src/lib/composio/types.ts | 11 + app/src/lib/i18n/chunks/ar-5.ts | 3 + app/src/lib/i18n/chunks/bn-5.ts | 3 + app/src/lib/i18n/chunks/de-5.ts | 3 + app/src/lib/i18n/chunks/en-5.ts | 3 + app/src/lib/i18n/chunks/es-5.ts | 3 + app/src/lib/i18n/chunks/fr-5.ts | 3 + app/src/lib/i18n/chunks/hi-5.ts | 3 + app/src/lib/i18n/chunks/id-5.ts | 3 + app/src/lib/i18n/chunks/it-5.ts | 3 + app/src/lib/i18n/chunks/ko-5.ts | 3 + app/src/lib/i18n/chunks/pt-5.ts | 3 + app/src/lib/i18n/chunks/ru-5.ts | 3 + app/src/lib/i18n/chunks/zh-CN-5.ts | 3 + app/src/lib/i18n/en.ts | 3 + app/src/pages/Skills.tsx | 58 ++++- .../__tests__/Skills.channels-grid.test.tsx | 8 + .../Skills.composio-catalog.test.tsx | 41 ++++ .../Skills.discovered-skills.test.tsx | 6 + .../Skills.third-party-gmail-sync.test.tsx | 6 + ...ls.third-party-notion-debug-tools.test.tsx | 6 + src/openhuman/composio/mod.rs | 4 +- src/openhuman/composio/ops.rs | 25 ++- src/openhuman/composio/providers/catalogs.rs | 6 +- .../composio/providers/catalogs_microsoft.rs | 207 ++++++++++++++++++ .../providers/catalogs_productivity.rs | 125 ++++++++++- .../composio/providers/descriptions.rs | 5 + src/openhuman/composio/providers/mod.rs | 115 ++++++++++ src/openhuman/composio/schemas.rs | 25 +++ src/openhuman/composio/tools_tests.rs | 15 +- src/openhuman/composio/types.rs | 11 + 35 files changed, 875 insertions(+), 16 deletions(-) create mode 100644 src/openhuman/composio/providers/catalogs_microsoft.rs diff --git a/app/src/lib/composio/composioApi.test.ts b/app/src/lib/composio/composioApi.test.ts index dad4f907d..3ee46d910 100644 --- a/app/src/lib/composio/composioApi.test.ts +++ b/app/src/lib/composio/composioApi.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { disableTrigger, enableTrigger, + listAgentReadyToolkits, listAvailableTriggers, listTriggers, syncConnection, @@ -146,3 +147,31 @@ describe('syncConnection', () => { expect(out).toBeNull(); }); }); + +describe('listAgentReadyToolkits', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('dispatches composio_list_agent_ready_toolkits and unwraps the envelope', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { toolkits: ['excel', 'gmail', 'one_drive', 'todoist'] }, + logs: ['composio: 4 agent-ready toolkit(s) listed'], + }); + + const out = await listAgentReadyToolkits(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.composio_list_agent_ready_toolkits', + }); + expect(out.toolkits).toContain('excel'); + expect(out.toolkits).toContain('one_drive'); + expect(out.toolkits).toContain('todoist'); + }); + + it('returns flat payload verbatim when the RPC layer did not wrap it', async () => { + mockCallCoreRpc.mockResolvedValue({ toolkits: ['gmail'] }); + const out = await listAgentReadyToolkits(); + expect(out.toolkits).toEqual(['gmail']); + }); +}); diff --git a/app/src/lib/composio/composioApi.ts b/app/src/lib/composio/composioApi.ts index e8502b3e3..4ac59c8eb 100644 --- a/app/src/lib/composio/composioApi.ts +++ b/app/src/lib/composio/composioApi.ts @@ -13,6 +13,7 @@ import { callCoreRpc } from '../../services/coreRpcClient'; import type { ComposioActiveTriggersResponse, + ComposioAgentReadyToolkitsResponse, ComposioAuthorizeResponse, ComposioAvailableTriggersResponse, ComposioConnectionsResponse, @@ -54,6 +55,24 @@ export async function listToolkits(): Promise { return unwrapCliEnvelope(raw); } +/** + * Fetch the slugs of toolkits that have an agent-ready curated + * catalog on the core side. The response is sorted alphabetically + * and is safe to cache once per session — the set only changes + * with core releases. + * + * Used by the Skills grid (issue #2283) to label connected + * toolkits without a catalog as "preview / coming soon" so users + * don't trigger the max-iterations failure that uncurated + * connections cause. + */ +export async function listAgentReadyToolkits(): Promise { + const raw = await callCoreRpc({ + method: 'openhuman.composio_list_agent_ready_toolkits', + }); + return unwrapCliEnvelope(raw); +} + export async function listConnections(): Promise { const raw = await callCoreRpc({ method: 'openhuman.composio_list_connections' }); return unwrapCliEnvelope(raw); diff --git a/app/src/lib/composio/hooks.test.ts b/app/src/lib/composio/hooks.test.ts index 62e7840c1..14c41fc4f 100644 --- a/app/src/lib/composio/hooks.test.ts +++ b/app/src/lib/composio/hooks.test.ts @@ -3,10 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockListToolkits = vi.fn(); const mockListConnections = vi.fn(); +const mockListAgentReadyToolkits = vi.fn(); vi.mock('./composioApi', () => ({ listToolkits: () => mockListToolkits(), listConnections: () => mockListConnections(), + listAgentReadyToolkits: () => mockListAgentReadyToolkits(), })); describe('useComposioIntegrations', () => { @@ -49,3 +51,66 @@ describe('useComposioIntegrations', () => { expect(result.current.error).toBe('backend unreachable'); }); }); + +describe('useAgentReadyComposioToolkits', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('returns a normalized Set of agent-ready toolkit slugs on success', async () => { + const { useAgentReadyComposioToolkits } = await import('./hooks'); + + mockListAgentReadyToolkits.mockResolvedValue({ + toolkits: ['gmail', 'one_drive', 'EXCEL', 'todoist'], + }); + + const { result } = renderHook(() => useAgentReadyComposioToolkits()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // canonicalizeComposioToolkitSlug normalizes case and aliases. + expect(result.current.agentReady.has('gmail')).toBe(true); + expect(result.current.agentReady.has('one_drive')).toBe(true); + expect(result.current.agentReady.has('excel')).toBe(true); + expect(result.current.agentReady.has('todoist')).toBe(true); + // Uncatalogued toolkit must NOT appear — the UI relies on this + // to drive the preview-badge logic (issue #2283). + expect(result.current.agentReady.has('clickup')).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('returns an empty set and surfaces error when the RPC fails', async () => { + const { useAgentReadyComposioToolkits } = await import('./hooks'); + + mockListAgentReadyToolkits.mockRejectedValue(new Error('rpc unavailable')); + + const { result } = renderHook(() => useAgentReadyComposioToolkits()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Failure must NOT label every toolkit as preview — surface the + // error and let the caller decide how to degrade. + expect(result.current.agentReady.size).toBe(0); + expect(result.current.error).toBe('rpc unavailable'); + }); + + it('handles a missing toolkits field without throwing', async () => { + const { useAgentReadyComposioToolkits } = await import('./hooks'); + + mockListAgentReadyToolkits.mockResolvedValue({}); + + const { result } = renderHook(() => useAgentReadyComposioToolkits()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.agentReady.size).toBe(0); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/app/src/lib/composio/hooks.ts b/app/src/lib/composio/hooks.ts index e6eb7ce2d..2372b64fe 100644 --- a/app/src/lib/composio/hooks.ts +++ b/app/src/lib/composio/hooks.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { listConnections, listToolkits } from './composioApi'; +import { listAgentReadyToolkits, listConnections, listToolkits } from './composioApi'; import { canonicalizeComposioToolkitSlug } from './toolkitSlug'; import type { ComposioConnection } from './types'; @@ -143,3 +143,63 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte return { toolkits, connectionByToolkit, loading, error, refresh }; } + +// ── useAgentReadyComposioToolkits ───────────────────────────────── + +export interface UseAgentReadyComposioToolkitsResult { + /** Lowercased slugs of toolkits that ship an agent-ready catalog. */ + agentReady: ReadonlySet; + /** Whether the initial fetch is still in flight. */ + loading: boolean; + /** Last error message from the fetch, if any. */ + error: string | null; +} + +/** + * Fetches the set of Composio toolkits that have an agent-ready + * curated catalog on the core side. The list changes only with + * core releases, so we fetch once on mount and never refresh. + * + * Used by the Skills grid (issue #2283) to flag connected + * toolkits without a catalog as "preview / coming soon" so users + * don't trigger the max-iterations failure that an uncurated + * connection causes when the agent calls `composio_list_tools`. + * + * On fetch failure we return an empty set and surface the error + * — the UI degrades to "no preview labels" rather than + * incorrectly labelling everything as preview. + */ +export function useAgentReadyComposioToolkits(): UseAgentReadyComposioToolkitsResult { + const [agentReady, setAgentReady] = useState>(() => new Set()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + listAgentReadyToolkits() + .then(resp => { + if (!mountedRef.current) return; + const normalized = (resp.toolkits ?? []).map(canonicalizeComposioToolkitSlug); + setAgentReady(new Set(normalized)); + setError(null); + }) + .catch(err => { + if (!mountedRef.current) return; + const message = err instanceof Error ? err.message : String(err); + console.warn('[composio] agent-ready toolkits fetch failed:', message); + setError(message); + }) + .finally(() => { + if (mountedRef.current) setLoading(false); + }); + }, []); + + return { agentReady, loading, error }; +} diff --git a/app/src/lib/composio/types.ts b/app/src/lib/composio/types.ts index 052ff936f..0719bc3fb 100644 --- a/app/src/lib/composio/types.ts +++ b/app/src/lib/composio/types.ts @@ -9,6 +9,17 @@ export interface ComposioToolkitsResponse { toolkits: string[]; } +/** + * Sorted list of toolkit slugs that ship a curated agent-ready + * catalog on the core side. Used by the Skills grid to label + * connected-but-uncurated toolkits as preview / coming soon so + * users don't trigger the max-iterations failure documented in + * issue #2283. + */ +export interface ComposioAgentReadyToolkitsResponse { + toolkits: string[]; +} + export interface ComposioConnection { id: string; toolkit: string; diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts index 4967ddf89..6aad0a5e0 100644 --- a/app/src/lib/i18n/chunks/ar-5.ts +++ b/app/src/lib/i18n/chunks/ar-5.ts @@ -433,6 +433,9 @@ const ar5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'فشل تبديل الصدى', 'composio.authExpired': 'انتهت صلاحية المصادقة', 'composio.reconnect': 'إعادة الاتصال', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'فشل الحفظ. الوضع المباشر يتطلب مفتاح API غير فارغ.', 'composio.notYetRouted': 'لم يتم توجيهه بعد', 'composio.triggers.loading': 'جارٍ التحميل…', diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts index 9d8c80e93..79c936456 100644 --- a/app/src/lib/i18n/chunks/bn-5.ts +++ b/app/src/lib/i18n/chunks/bn-5.ts @@ -440,6 +440,9 @@ const bn5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'ইকো টগল করতে ব্যর্থ', 'composio.authExpired': 'অথ মেয়াদোত্তীর্ণ', 'composio.reconnect': 'পুনঃসংযোগ', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'সংরক্ষণ ব্যর্থ। Direct মোডের জন্য একটি API key প্রয়োজন।', 'composio.notYetRouted': 'এখনও রুট করা হয়নি', 'composio.triggers.loading': 'লোড হচ্ছে…', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index c698c292f..1c528b294 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -457,6 +457,9 @@ const de5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Echo konnte nicht umgeschaltet werden', 'composio.authExpired': 'Authentifizierung abgelaufen', 'composio.reconnect': 'Wieder verbinden', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'Speichern fehlgeschlagen. Der Direktmodus erfordert einen nicht leeren Schlüssel API.', 'composio.notYetRouted': 'noch nicht geroutet', diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index f1470083d..8cac51889 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -440,6 +440,9 @@ const en5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Failed to toggle echo', 'composio.authExpired': 'Auth expired', 'composio.reconnect': 'Reconnect', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'Failed to save. Direct mode requires a non-empty API key.', 'composio.notYetRouted': 'not yet routed', 'composio.triggers.loading': 'Loading…', diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts index 85a2e41f1..dba785c5d 100644 --- a/app/src/lib/i18n/chunks/es-5.ts +++ b/app/src/lib/i18n/chunks/es-5.ts @@ -444,6 +444,9 @@ const es5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'No se pudo alternar el echo', 'composio.authExpired': 'Autenticación caducada', 'composio.reconnect': 'Reconectar', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'Error al guardar. El modo Directo requiere una clave API no vacía.', 'composio.notYetRouted': 'aún sin enrutar', diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts index ff051e8ec..dbe9c12c4 100644 --- a/app/src/lib/i18n/chunks/fr-5.ts +++ b/app/src/lib/i18n/chunks/fr-5.ts @@ -448,6 +448,9 @@ const fr5: TranslationMap = { 'webhooks.tunnels.toggleFailed': "Échec de la bascule de l'écho", 'composio.authExpired': 'Authentification expirée', 'composio.reconnect': 'Reconnecter', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': "Échec de l'enregistrement. Le mode Direct nécessite une clé API non vide.", 'composio.notYetRouted': 'pas encore routé', diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts index 06b3db21f..7dd3617ff 100644 --- a/app/src/lib/i18n/chunks/hi-5.ts +++ b/app/src/lib/i18n/chunks/hi-5.ts @@ -441,6 +441,9 @@ const hi5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Echo टॉगल करने में दिक्कत', 'composio.authExpired': 'प्रमाणीकरण समाप्त', 'composio.reconnect': 'पुनः कनेक्ट करें', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'सहेजने में विफल। डायरेक्ट मोड के लिए गैर-रिक्त API कुंजी आवश्यक है।', 'composio.notYetRouted': 'अभी तक रूट नहीं हुआ', diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts index fa7301197..70a6dcf4c 100644 --- a/app/src/lib/i18n/chunks/id-5.ts +++ b/app/src/lib/i18n/chunks/id-5.ts @@ -441,6 +441,9 @@ const id5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Gagal mengalihkan echo', 'composio.authExpired': 'Autentikasi kedaluwarsa', 'composio.reconnect': 'Hubungkan ulang', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'Gagal menyimpan. Mode Direct memerlukan API key yang tidak kosong.', 'composio.notYetRouted': 'belum dirutekan', diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts index efd5f22a2..bed88b813 100644 --- a/app/src/lib/i18n/chunks/it-5.ts +++ b/app/src/lib/i18n/chunks/it-5.ts @@ -445,6 +445,9 @@ const it5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Attivazione echo fallita', 'composio.authExpired': 'Autenticazione scaduta', 'composio.reconnect': 'Riconnetti', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'Salvataggio fallito. La modalità Diretta richiede una chiave API non vuota.', 'composio.notYetRouted': 'non ancora instradato', diff --git a/app/src/lib/i18n/chunks/ko-5.ts b/app/src/lib/i18n/chunks/ko-5.ts index c4d6be9b1..37c5fb8c7 100644 --- a/app/src/lib/i18n/chunks/ko-5.ts +++ b/app/src/lib/i18n/chunks/ko-5.ts @@ -402,6 +402,9 @@ const ko5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Echo 전환 실패', 'composio.authExpired': '인증이 만료됨', 'composio.reconnect': '다시 연결', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': '저장에 실패했습니다. Direct 모드에는 비어 있지 않은 API 키가 필요합니다.', 'composio.notYetRouted': '아직 라우팅되지 않음', diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts index be3abfe5b..9a9ac5e88 100644 --- a/app/src/lib/i18n/chunks/pt-5.ts +++ b/app/src/lib/i18n/chunks/pt-5.ts @@ -445,6 +445,9 @@ const pt5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Falha ao alternar echo', 'composio.authExpired': 'Autenticação expirada', 'composio.reconnect': 'Reconectar', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'Falha ao salvar. O modo Direto requer uma chave de API não vazia.', 'composio.notYetRouted': 'ainda não roteado', diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts index acb245950..c82e380d5 100644 --- a/app/src/lib/i18n/chunks/ru-5.ts +++ b/app/src/lib/i18n/chunks/ru-5.ts @@ -442,6 +442,9 @@ const ru5: TranslationMap = { 'webhooks.tunnels.toggleFailed': 'Не удалось переключить эхо', 'composio.authExpired': 'Срок авторизации истёк', 'composio.reconnect': 'Переподключить', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': 'Не удалось сохранить. Прямой режим требует непустой API-ключ.', 'composio.notYetRouted': 'пока не маршрутизируется', 'composio.triggers.loading': 'Загрузка…', diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts index 8c6a4f068..149b85dc6 100644 --- a/app/src/lib/i18n/chunks/zh-CN-5.ts +++ b/app/src/lib/i18n/chunks/zh-CN-5.ts @@ -416,6 +416,9 @@ const zhCN5: TranslationMap = { 'webhooks.tunnels.toggleFailed': '切换回显失败', 'composio.authExpired': '授权已过期', 'composio.reconnect': '重新连接', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', 'composio.directModeRequiresKey': '保存失败。直连模式需要非空的 API 密钥。', 'composio.notYetRouted': '尚未路由', 'composio.triggers.loading': '加载中…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 11de47012..ce500411f 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1190,6 +1190,9 @@ const en: TranslationMap = { 'composio.authExpired': 'Auth expired', 'composio.reconnect': 'Reconnect', 'composio.envVarOverrides': 'is set, it overrides this setting.', + 'composio.previewBadge': 'Preview', + 'composio.previewTooltip': + 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', // Memory: day-of-week labels for heatmap 'memory.day.sun': 'Sun', diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 4e389de20..f82ac6605 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -31,7 +31,7 @@ import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocomp import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligence/useScreenIntelligenceSkillStatus'; import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus'; import { useChannelDefinitions } from '../hooks/useChannelDefinitions'; -import { useComposioIntegrations } from '../lib/composio/hooks'; +import { useAgentReadyComposioToolkits, useComposioIntegrations } from '../lib/composio/hooks'; import { canonicalizeComposioToolkitSlug } from '../lib/composio/toolkitSlug'; import { type ComposioConnection, deriveComposioState } from '../lib/composio/types'; import { useT } from '../lib/i18n/I18nContext'; @@ -125,6 +125,13 @@ interface ComposioConnectorTileProps { meta: ComposioToolkitMeta; connection: ComposioConnection | undefined; hasComposioError: boolean; + /** + * Whether this toolkit has a curated agent-ready catalog on the + * core. Connected toolkits without a catalog show a "Preview" + * badge so users know the agent can't act on them yet — see + * issue #2283. + */ + isAgentReady: boolean; testId?: string; onOpen: () => void; onRetryGlobal: () => void; @@ -134,6 +141,7 @@ function ComposioConnectorTile({ meta, connection, hasComposioError, + isAgentReady, testId, onOpen, onRetryGlobal, @@ -159,6 +167,12 @@ function ComposioConnectorTile({ const isPending = state === 'pending'; const isExpired = state === 'expired'; const isError = state === 'error' || hasComposioError; + // Show the preview badge for connected toolkits without a curated + // catalog, AND for unconnected uncurated toolkits so users see the + // limitation BEFORE going through OAuth (issue #2283). + const showPreviewBadge = !isAgentReady && (isConnected || (!isPending && !isExpired && !isError)); + const previewLabel = t('composio.previewBadge'); + const previewTooltip = t('composio.previewTooltip'); const handleClick = () => { if (hasComposioError) { @@ -173,8 +187,16 @@ function ComposioConnectorTile({ type="button" data-testid={testId} onClick={handleClick} - title={`${meta.name} — ${meta.description}`} - aria-label={`${meta.name}, ${statusLabel}. ${ctaLabel}.`} + title={ + showPreviewBadge + ? `${meta.name} — ${meta.description} (${previewTooltip})` + : `${meta.name} — ${meta.description}` + } + aria-label={ + showPreviewBadge + ? `${meta.name}, ${statusLabel}, ${previewLabel}. ${ctaLabel}.` + : `${meta.name}, ${statusLabel}. ${ctaLabel}.` + } className={`group flex flex-col justify-center items-center rounded-2xl border p-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${ isConnected ? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50 dark:border-sage-500/30 dark:bg-sage-500/10 dark:hover:bg-sage-500/15' @@ -186,6 +208,13 @@ function ComposioConnectorTile({ }`}>
{meta.icon} + {showPreviewBadge && ( + + {previewLabel} + + )}
@@ -306,6 +335,24 @@ export default function Skills() { refresh: refreshComposio, } = useComposioIntegrations(); + // Set of curated agent-ready toolkit slugs — see issue #2283. We + // intentionally do NOT block UI rendering on this fetch; while + // loading we treat every toolkit as agent-ready (no preview + // badges) and only flip on uncurated toolkits once the response + // arrives. This avoids a flash of preview-badges on the curated + // tiles during the initial paint. + // + // When the RPC FAILS (non-loading, empty set, non-null error), we + // also default to "agent-ready" so curated toolkits don't all + // light up with a misleading Preview badge — the UI gracefully + // degrades to the pre-#2283 behaviour rather than misrepresenting + // the agent surface (CodeRabbit review on PR #2361). + const { + agentReady: agentReadyToolkits, + loading: agentReadyLoading, + error: agentReadyError, + } = useAgentReadyComposioToolkits(); + const [channelModalDef, setChannelModalDef] = useState(null); const [composioModalToolkit, setComposioModalToolkit] = useState( null @@ -886,6 +933,11 @@ export default function Skills() { meta={meta} connection={connection} hasComposioError={Boolean(composioError)} + isAgentReady={ + agentReadyLoading || + Boolean(agentReadyError) || + agentReadyToolkits.has(meta.slug) + } testId={`skill-install-composio-${meta.slug}`} onOpen={() => setComposioModalToolkit(meta)} onRetryGlobal={() => void refreshComposio()} diff --git a/app/src/pages/__tests__/Skills.channels-grid.test.tsx b/app/src/pages/__tests__/Skills.channels-grid.test.tsx index 77647cb18..528754f36 100644 --- a/app/src/pages/__tests__/Skills.channels-grid.test.tsx +++ b/app/src/pages/__tests__/Skills.channels-grid.test.tsx @@ -50,6 +50,14 @@ vi.mock('../../lib/composio/hooks', () => ({ loading: false, error: null, }), + // Issue #2283: Skills.tsx also consumes useAgentReadyComposioToolkits. + // `loading: true` keeps Preview badges off so legacy aria-label + // assertions on this page keep passing. + useAgentReadyComposioToolkits: () => ({ + agentReady: new Set(), + loading: true, + error: null, + }), })); describe('Skills page — Channels grid', () => { diff --git a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx index 53f257d8f..55fc941ee 100644 --- a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx +++ b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx @@ -9,6 +9,15 @@ let composioRefresh = vi.fn(); let composioError: string | null = null; let composioToolkits: string[] = []; let composioConnectionByToolkit = new Map(); +// CodeRabbit on #2361: failure-path coverage for the agent-ready +// RPC requires overriding the hook's state per test. Default state +// keeps Preview badges off (loading=true) so legacy assertions on +// this file don't drift. +let agentReadyState: { agentReady: Set; loading: boolean; error: string | null } = { + agentReady: new Set(), + loading: true, + error: null, +}; vi.mock('../../hooks/useChannelDefinitions', () => ({ useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }), @@ -30,6 +39,11 @@ vi.mock('../../lib/composio/hooks', () => ({ loading: false, error: composioError, }), + // Issue #2283 / CodeRabbit on #2361: Skills.tsx consumes + // useAgentReadyComposioToolkits. We route through a module-level + // `agentReadyState` so individual tests can override `loading` / + // `error` to exercise the failure-fallback path. + useAgentReadyComposioToolkits: () => agentReadyState, })); describe('Skills page — Composio catalog fallback', () => { @@ -38,6 +52,7 @@ describe('Skills page — Composio catalog fallback', () => { composioError = null; composioToolkits = []; composioConnectionByToolkit = new Map(); + agentReadyState = { agentReady: new Set(), loading: true, error: null }; }); it('shows known composio integrations in the integrations icon grid when the live toolkit list is empty', () => { @@ -112,4 +127,30 @@ describe('Skills page — Composio catalog fallback', () => { expect(screen.getByText(/Gmail authorization expired/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Reconnect Gmail/i })).toBeInTheDocument(); }); + + it('does not flood the integrations grid with Preview badges when the agent-ready RPC fails', () => { + // CodeRabbit on #2361: when the agent-ready hook errors out + // (loading=false, agentReady=empty, error set), we must NOT + // label every curated toolkit as Preview — the UI has no + // signal to draw that conclusion. Skills.tsx now falls back to + // treating every toolkit as agent-ready in this state so the + // page degrades to the pre-#2283 behaviour instead of + // misrepresenting the agent surface. + agentReadyState = { agentReady: new Set(), loading: false, error: 'rpc unavailable' }; + + renderWithProviders(, { initialEntries: ['/skills'] }); + + const integrationsSection = screen + .getByRole('heading', { name: 'Integrations' }) + .closest('.rounded-2xl'); + expect(integrationsSection).not.toBeNull(); + // No Preview badges anywhere in the integrations grid. The + // badge carries a `data-testid` of the form + // `composio-preview-badge-`; absence means we degraded + // gracefully on RPC failure. + const previewBadges = within(integrationsSection as HTMLElement).queryAllByTestId( + /composio-preview-badge-/ + ); + expect(previewBadges).toHaveLength(0); + }); }); diff --git a/app/src/pages/__tests__/Skills.discovered-skills.test.tsx b/app/src/pages/__tests__/Skills.discovered-skills.test.tsx index e268822f0..6f3232ec3 100644 --- a/app/src/pages/__tests__/Skills.discovered-skills.test.tsx +++ b/app/src/pages/__tests__/Skills.discovered-skills.test.tsx @@ -53,6 +53,12 @@ vi.mock('../../lib/composio/hooks', () => ({ loading: false, error: null, }), + // Issue #2283: Skills.tsx also consumes useAgentReadyComposioToolkits. + useAgentReadyComposioToolkits: () => ({ + agentReady: new Set(), + loading: true, + error: null, + }), })); describe('Skills page — discovered skill cards', () => { diff --git a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx index a2f76714b..2b13d49df 100644 --- a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx @@ -27,6 +27,12 @@ vi.mock('../../lib/composio/hooks', () => ({ loading: false, error: null, }), + // Issue #2283: Skills.tsx also consumes useAgentReadyComposioToolkits. + useAgentReadyComposioToolkits: () => ({ + agentReady: new Set(), + loading: true, + error: null, + }), })); describe('Skills page — Gmail composio integration', () => { diff --git a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx index cde8157e1..7583ca64d 100644 --- a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx @@ -25,6 +25,12 @@ vi.mock('../../lib/composio/hooks', () => ({ loading: false, error: null, }), + // Issue #2283: Skills.tsx also consumes useAgentReadyComposioToolkits. + useAgentReadyComposioToolkits: () => ({ + agentReady: new Set(), + loading: true, + error: null, + }), })); describe('Skills page — Notion composio integration', () => { diff --git a/src/openhuman/composio/mod.rs b/src/openhuman/composio/mod.rs index 9810fecb2..1831b35d2 100644 --- a/src/openhuman/composio/mod.rs +++ b/src/openhuman/composio/mod.rs @@ -78,8 +78,8 @@ pub use trigger_history::{ global as global_composio_trigger_history, init_global as init_composio_trigger_history, }; pub use types::{ - ComposioAuthorizeResponse, ComposioCapabilitiesResponse, ComposioCapability, - ComposioConnection, ComposioConnectionsResponse, ComposioDeleteResponse, + ComposioAgentReadyToolkitsResponse, ComposioAuthorizeResponse, ComposioCapabilitiesResponse, + ComposioCapability, ComposioConnection, ComposioConnectionsResponse, ComposioDeleteResponse, ComposioExecuteResponse, ComposioToolFunction, ComposioToolSchema, ComposioToolkitsResponse, ComposioToolsResponse, ComposioTriggerEvent, ComposioTriggerHistoryEntry, ComposioTriggerHistoryResult, ComposioTriggerMetadata, diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index 7871812de..83774e7b2 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -26,7 +26,8 @@ use super::client::{ direct_list_tools, ComposioClient, ComposioClientKind, }; use super::providers::{ - capability_matrix, get_provider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, + agent_ready_toolkits, capability_matrix, get_provider, ProviderContext, ProviderUserProfile, + SyncOutcome, SyncReason, }; use super::types::{ ComposioActiveTriggersResponse, ComposioAuthorizeResponse, ComposioAvailableTriggersResponse, @@ -211,6 +212,28 @@ pub async fn composio_list_capabilities( )) } +/// List every toolkit slug that ships an agent-ready curated catalog. +/// +/// Connected toolkits that are NOT in this list can still be +/// authorized via OAuth, but the agent has no curated action surface +/// for them — the UI should label such connections as +/// "preview / agent integration coming soon" so users aren't led into +/// a broken `composio_list_tools` → max-iterations loop. See #2283. +pub async fn composio_list_agent_ready_toolkits( +) -> OpResult> { + tracing::debug!("[composio] rpc list_agent_ready_toolkits"); + let toolkits: Vec = agent_ready_toolkits() + .into_iter() + .map(|s| s.to_string()) + .collect(); + let count = toolkits.len(); + let resp = super::types::ComposioAgentReadyToolkitsResponse { toolkits }; + Ok(RpcOutcome::new( + resp, + vec![format!("composio: {count} agent-ready toolkit(s) listed")], + )) +} + // ── Connections ───────────────────────────────────────────────────── pub async fn composio_list_connections( diff --git a/src/openhuman/composio/providers/catalogs.rs b/src/openhuman/composio/providers/catalogs.rs index 3b58206db..180f17ceb 100644 --- a/src/openhuman/composio/providers/catalogs.rs +++ b/src/openhuman/composio/providers/catalogs.rs @@ -12,7 +12,8 @@ //! Data is split into category submodules: //! - [`catalogs_messaging`] — Slack, Discord, Telegram, WhatsApp, MS Teams //! - [`catalogs_google`] — GoogleCalendar, GoogleDrive, GoogleDocs, GoogleSheets -//! - [`catalogs_productivity`] — Outlook, Linear, Jira, Trello, Asana, Dropbox +//! - [`catalogs_microsoft`] — OneDrive, Excel +//! - [`catalogs_productivity`] — Outlook, Linear, Jira, Trello, Asana, Dropbox, Todoist //! - [`catalogs_social_media`] — Twitter, Spotify, YouTube //! - [`catalogs_business`] — Shopify, Stripe, HubSpot, Salesforce, Airtable, Figma @@ -26,7 +27,8 @@ pub use super::catalogs_google::{ pub use super::catalogs_messaging::{ DISCORD_CURATED, MICROSOFT_TEAMS_CURATED, SLACK_CURATED, TELEGRAM_CURATED, WHATSAPP_CURATED, }; +pub use super::catalogs_microsoft::{EXCEL_CURATED, ONE_DRIVE_CURATED}; pub use super::catalogs_productivity::{ - ASANA_CURATED, DROPBOX_CURATED, JIRA_CURATED, OUTLOOK_CURATED, TRELLO_CURATED, + ASANA_CURATED, DROPBOX_CURATED, JIRA_CURATED, OUTLOOK_CURATED, TODOIST_CURATED, TRELLO_CURATED, }; pub use super::catalogs_social_media::{SPOTIFY_CURATED, TWITTER_CURATED, YOUTUBE_CURATED}; diff --git a/src/openhuman/composio/providers/catalogs_microsoft.rs b/src/openhuman/composio/providers/catalogs_microsoft.rs new file mode 100644 index 000000000..cd54ede99 --- /dev/null +++ b/src/openhuman/composio/providers/catalogs_microsoft.rs @@ -0,0 +1,207 @@ +//! Curated catalogs — Microsoft personal-productivity toolkits: +//! OneDrive (files) and Excel (spreadsheets). +//! +//! These toolkits are catalog-only: they don't ship a native +//! [`super::ComposioProvider`] implementation, so they have no +//! user-profile fetch, no initial/periodic sync, no trigger webhooks, +//! and no memory ingestion. Connecting them via the UI lets the agent +//! invoke the listed actions through Composio's API, but their data +//! is not pre-ingested into OpenHuman's memory tree. +//! +//! Action slugs are sourced best-effort from +//! `https://docs.composio.dev/toolkits/.md`. Slugs that don't +//! exist on the backend simply never appear in `composio_list_tools`, +//! so over-shooting is harmless. + +use super::tool_scope::{CuratedTool, ToolScope}; + +// ── onedrive ──────────────────────────────────────────────────────── +pub const ONE_DRIVE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "ONE_DRIVE_GET_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ONE_DRIVE_GET_FILE_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ONE_DRIVE_LIST_FILES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ONE_DRIVE_LIST_CHILDREN", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ONE_DRIVE_SEARCH_FILES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ONE_DRIVE_DOWNLOAD_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ONE_DRIVE_GET_DRIVE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ONE_DRIVE_UPLOAD_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ONE_DRIVE_CREATE_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ONE_DRIVE_COPY_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ONE_DRIVE_MOVE_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ONE_DRIVE_UPDATE_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ONE_DRIVE_CREATE_SHARE_LINK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ONE_DRIVE_DELETE_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ONE_DRIVE_DELETE_FOLDER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ONE_DRIVE_RESTORE_FILE", + scope: ToolScope::Admin, + }, +]; + +// ── excel ─────────────────────────────────────────────────────────── +pub const EXCEL_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "EXCEL_GET_WORKBOOK", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "EXCEL_LIST_WORKSHEETS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "EXCEL_GET_WORKSHEET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "EXCEL_GET_RANGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "EXCEL_GET_USED_RANGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "EXCEL_LIST_TABLES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "EXCEL_GET_TABLE_ROWS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "EXCEL_CREATE_WORKSHEET", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "EXCEL_UPDATE_RANGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "EXCEL_APPEND_ROWS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "EXCEL_INSERT_TABLE_ROW", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "EXCEL_UPDATE_TABLE_ROW", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "EXCEL_CREATE_TABLE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "EXCEL_FORMAT_RANGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "EXCEL_DELETE_WORKSHEET", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "EXCEL_DELETE_TABLE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "EXCEL_DELETE_TABLE_ROW", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "EXCEL_CLEAR_RANGE", + scope: ToolScope::Admin, + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_drive_catalog_is_non_empty_and_unique() { + assert!(!ONE_DRIVE_CURATED.is_empty()); + let mut slugs: Vec<&'static str> = ONE_DRIVE_CURATED.iter().map(|t| t.slug).collect(); + slugs.sort_unstable(); + slugs.dedup(); + assert_eq!(slugs.len(), ONE_DRIVE_CURATED.len()); + for tool in ONE_DRIVE_CURATED { + assert!(tool.slug.starts_with("ONE_DRIVE_")); + } + } + + #[test] + fn excel_catalog_is_non_empty_and_unique() { + assert!(!EXCEL_CURATED.is_empty()); + let mut slugs: Vec<&'static str> = EXCEL_CURATED.iter().map(|t| t.slug).collect(); + slugs.sort_unstable(); + slugs.dedup(); + assert_eq!(slugs.len(), EXCEL_CURATED.len()); + for tool in EXCEL_CURATED { + assert!(tool.slug.starts_with("EXCEL_")); + } + } + + #[test] + fn one_drive_catalog_covers_all_three_scopes() { + assert!(ONE_DRIVE_CURATED.iter().any(|t| t.scope == ToolScope::Read)); + assert!(ONE_DRIVE_CURATED + .iter() + .any(|t| t.scope == ToolScope::Write)); + assert!(ONE_DRIVE_CURATED + .iter() + .any(|t| t.scope == ToolScope::Admin)); + } + + #[test] + fn excel_catalog_covers_all_three_scopes() { + assert!(EXCEL_CURATED.iter().any(|t| t.scope == ToolScope::Read)); + assert!(EXCEL_CURATED.iter().any(|t| t.scope == ToolScope::Write)); + assert!(EXCEL_CURATED.iter().any(|t| t.scope == ToolScope::Admin)); + } +} diff --git a/src/openhuman/composio/providers/catalogs_productivity.rs b/src/openhuman/composio/providers/catalogs_productivity.rs index a599404ec..83ab6920f 100644 --- a/src/openhuman/composio/providers/catalogs_productivity.rs +++ b/src/openhuman/composio/providers/catalogs_productivity.rs @@ -1,5 +1,12 @@ //! Curated catalogs — productivity toolkits: Outlook, Linear, Jira, -//! Trello, Asana, Dropbox. +//! Trello, Asana, Dropbox, Todoist. +//! +//! Catalog-only toolkits (Linear, Jira, Trello, Asana, Dropbox, +//! Todoist) don't ship a native [`super::ComposioProvider`] — they +//! have no user-profile fetch, no initial/periodic sync, no trigger +//! webhooks, and no memory ingestion. The agent invokes their actions +//! through Composio's API, but their data is not pre-ingested into +//! OpenHuman's memory tree. use super::tool_scope::{CuratedTool, ToolScope}; @@ -462,3 +469,119 @@ pub const DROPBOX_CURATED: &[CuratedTool] = &[ scope: ToolScope::Admin, }, ]; + +// ── todoist ───────────────────────────────────────────────────────── +pub const TODOIST_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "TODOIST_GET_TASK", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_GET_ACTIVE_TASKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_GET_COMPLETED_TASKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_GET_PROJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_GET_PROJECT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_GET_SECTIONS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_GET_LABELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_GET_COMMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TODOIST_CREATE_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_UPDATE_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_CLOSE_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_REOPEN_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_CREATE_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_UPDATE_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_CREATE_SECTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_CREATE_LABEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_CREATE_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TODOIST_DELETE_TASK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TODOIST_DELETE_PROJECT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TODOIST_DELETE_SECTION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TODOIST_DELETE_LABEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TODOIST_DELETE_COMMENT", + scope: ToolScope::Admin, + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn todoist_catalog_is_non_empty_and_unique() { + assert!(!TODOIST_CURATED.is_empty()); + let mut slugs: Vec<&'static str> = TODOIST_CURATED.iter().map(|t| t.slug).collect(); + slugs.sort_unstable(); + slugs.dedup(); + assert_eq!(slugs.len(), TODOIST_CURATED.len()); + for tool in TODOIST_CURATED { + assert!(tool.slug.starts_with("TODOIST_")); + } + } + + #[test] + fn todoist_catalog_covers_all_three_scopes() { + assert!(TODOIST_CURATED.iter().any(|t| t.scope == ToolScope::Read)); + assert!(TODOIST_CURATED.iter().any(|t| t.scope == ToolScope::Write)); + assert!(TODOIST_CURATED.iter().any(|t| t.scope == ToolScope::Admin)); + } +} diff --git a/src/openhuman/composio/providers/descriptions.rs b/src/openhuman/composio/providers/descriptions.rs index 99fa0e623..488ae0ad3 100644 --- a/src/openhuman/composio/providers/descriptions.rs +++ b/src/openhuman/composio/providers/descriptions.rs @@ -42,6 +42,11 @@ pub fn toolkit_description(slug: &str) -> &'static str { "figma" => "Access and manage Figma design files and components", "youtube" => "Search videos, manage playlists, and interact with YouTube", "calendar" => "Create, update, and query calendar events", + "one_drive" | "onedrive" => { + "Upload, download, search, and share files in Microsoft OneDrive" + } + "excel" => "Read, write, and manage workbooks, worksheets, and tables in Microsoft Excel", + "todoist" => "Create and manage tasks, projects, sections, and labels in Todoist", _ => "Interact with this connected service via its available actions", } } diff --git a/src/openhuman/composio/providers/mod.rs b/src/openhuman/composio/providers/mod.rs index 1d4b71975..d1b9d5eb4 100644 --- a/src/openhuman/composio/providers/mod.rs +++ b/src/openhuman/composio/providers/mod.rs @@ -44,6 +44,7 @@ pub mod catalogs; pub mod catalogs_business; pub mod catalogs_google; pub mod catalogs_messaging; +pub mod catalogs_microsoft; pub mod catalogs_productivity; pub mod catalogs_social_media; pub mod clickup; @@ -88,6 +89,9 @@ const CAPABILITY_TOOLKITS: &[&str] = &[ "airtable", "figma", "youtube", + "one_drive", + "excel", + "todoist", ]; fn native_provider_sync_interval(toolkit: &str) -> Option { @@ -210,10 +214,63 @@ pub fn catalog_for_toolkit(toolkit: &str) -> Option<&'static [CuratedTool]> { "airtable" => Some(catalogs::AIRTABLE_CURATED), "figma" => Some(catalogs::FIGMA_CURATED), "youtube" => Some(catalogs::YOUTUBE_CURATED), + // ONE_DRIVE_* slugs extract to "one" via toolkit_from_slug; + // alias both the prefix and the canonical UI/backend slugs. + "one" | "one_drive" | "onedrive" => Some(catalogs::ONE_DRIVE_CURATED), + "excel" => Some(catalogs::EXCEL_CURATED), + "todoist" => Some(catalogs::TODOIST_CURATED), _ => None, } } +/// All toolkit slugs that have a curated agent-ready catalog. +/// +/// Source of truth for the UI "preview / agent integration coming +/// soon" badge: any connected toolkit whose slug is NOT in this list +/// can be authorized but lacks a curated tool surface, so the agent +/// can't use it productively. +/// +/// Returned in sorted order to keep the RPC response stable across +/// builds. +pub fn agent_ready_toolkits() -> Vec<&'static str> { + let mut slugs: Vec<&'static str> = vec![ + // Native providers + "gmail", + "notion", + "github", + // Catalog-only toolkits + "slack", + "discord", + "googlecalendar", + "googledrive", + "googledocs", + "googlesheets", + "outlook", + "microsoft_teams", + "linear", + "jira", + "trello", + "asana", + "dropbox", + "twitter", + "spotify", + "telegram", + "whatsapp", + "shopify", + "stripe", + "hubspot", + "salesforce", + "airtable", + "figma", + "youtube", + "one_drive", + "excel", + "todoist", + ]; + slugs.sort_unstable(); + slugs +} + pub use descriptions::toolkit_description; pub(crate) use helpers::pick_str; pub use registry::{ @@ -294,6 +351,64 @@ mod tests { // Note: `toolkit_has_scope` tests now live in `scope_lookup.rs` // alongside the implementation. + #[test] + fn catalog_for_toolkit_resolves_new_microsoft_and_todoist_slugs() { + // Newly added catalogs (#2283): OneDrive, Excel, Todoist must be + // discoverable both by their canonical UI slug AND by the + // prefix that `toolkit_from_slug` extracts from action slugs. + assert!(catalog_for_toolkit("one_drive").is_some()); + assert!(catalog_for_toolkit("onedrive").is_some()); + // ONE_DRIVE_GET_FILE → toolkit_from_slug() → "one" + assert!(catalog_for_toolkit("one").is_some()); + assert!(catalog_for_toolkit("excel").is_some()); + assert!(catalog_for_toolkit("todoist").is_some()); + } + + #[test] + fn agent_ready_toolkits_includes_new_catalogs_and_is_sorted() { + let slugs = agent_ready_toolkits(); + assert!(slugs.contains(&"one_drive")); + assert!(slugs.contains(&"excel")); + assert!(slugs.contains(&"todoist")); + // Spot-check legacy entries still present. + assert!(slugs.contains(&"gmail")); + assert!(slugs.contains(&"slack")); + // Uncurated toolkit must NOT appear — guarantees the UI badge + // logic can rely on this set to flag "preview" toolkits. + assert!(!slugs.contains(&"sharepoint")); + assert!(!slugs.contains(&"clickup")); + // Stable order across builds — the RPC consumer caches it. + let mut expected = slugs.clone(); + expected.sort_unstable(); + assert_eq!(slugs, expected); + } + + #[test] + fn capability_matrix_includes_new_catalog_only_toolkits() { + let matrix = capability_matrix(); + for slug in ["one_drive", "excel", "todoist"] { + let row = matrix + .iter() + .find(|entry| entry.toolkit == slug) + .unwrap_or_else(|| panic!("{slug} capability row missing")); + assert!(!row.native_provider, "{slug} should not be native"); + assert!(row.curated_tools, "{slug} should be catalogued"); + assert!( + row.curated_tool_count > 0, + "{slug} catalog should be non-empty" + ); + assert!( + row.tool_execution, + "{slug} tool execution should be enabled" + ); + // No profile/sync/memory ingest — catalog-only. + assert!(!row.user_profile); + assert!(!row.initial_sync); + assert!(!row.periodic_sync); + assert!(!row.memory_ingest); + } + } + #[test] fn capability_matrix_distinguishes_native_from_catalog_only_toolkits() { let matrix = capability_matrix(); diff --git a/src/openhuman/composio/schemas.rs b/src/openhuman/composio/schemas.rs index c77953ffc..e527f5821 100644 --- a/src/openhuman/composio/schemas.rs +++ b/src/openhuman/composio/schemas.rs @@ -4,6 +4,7 @@ //! `openhuman.composio_*`: //! - `composio.list_toolkits` → `openhuman.composio_list_toolkits` //! - `composio.list_capabilities` → `openhuman.composio_list_capabilities` +//! - `composio.list_agent_ready_toolkits` → `openhuman.composio_list_agent_ready_toolkits` //! - `composio.list_connections` → `openhuman.composio_list_connections` //! - `composio.authorize` → `openhuman.composio_authorize` //! - `composio.delete_connection` → `openhuman.composio_delete_connection` @@ -62,6 +63,7 @@ pub fn all_controller_schemas() -> Vec { vec![ schemas("list_toolkits"), schemas("list_capabilities"), + schemas("list_agent_ready_toolkits"), schemas("list_connections"), schemas("authorize"), schemas("delete_connection"), @@ -95,6 +97,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("list_capabilities"), handler: handle_list_capabilities, }, + RegisteredController { + schema: schemas("list_agent_ready_toolkits"), + handler: handle_list_agent_ready_toolkits, + }, RegisteredController { schema: schemas("list_connections"), handler: handle_list_connections, @@ -204,6 +210,21 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "list_agent_ready_toolkits" => ControllerSchema { + namespace: "composio", + function: "list_agent_ready_toolkits", + description: + "List every toolkit slug that ships an agent-ready curated catalog. Connected \ + toolkits not in this list should be surfaced in the UI as preview / agent \ + integration coming soon. See issue #2283.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "toolkits", + ty: TypeSchema::Array(Box::new(TypeSchema::String)), + comment: "Sorted toolkit slugs with curated catalogs (e.g. gmail, notion, one_drive, excel, todoist).", + required: true, + }], + }, "list_connections" => ControllerSchema { namespace: "composio", function: "list_connections", @@ -706,6 +727,10 @@ fn handle_list_capabilities(_params: Map) -> ControllerFuture { }) } +fn handle_list_agent_ready_toolkits(_params: Map) -> ControllerFuture { + Box::pin(async { to_json(super::ops::composio_list_agent_ready_toolkits().await?) }) +} + fn handle_list_connections(_params: Map) -> ControllerFuture { Box::pin(async { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/composio/tools_tests.rs b/src/openhuman/composio/tools_tests.rs index 86eca710b..687d782ac 100644 --- a/src/openhuman/composio/tools_tests.rs +++ b/src/openhuman/composio/tools_tests.rs @@ -619,17 +619,20 @@ fn normalized_scope_toolkits_prefers_requested_filter() { #[test] fn empty_uncurated_toolkits_message_names_agent_unsupported_toolkits() { + // Use slugs that have no curated catalog so the message is generated. + // onedrive/excel/todoist are catalogued as of #2361, so they're no + // longer uncurated and must not be used here. let message = empty_uncurated_toolkits_message(&[ - "onedrive".to_string(), - "excel".to_string(), - "todoist".to_string(), + "sharepoint".to_string(), + "monday".to_string(), + "intercom".to_string(), ]) .expect("uncurated toolkit message"); assert!(message.contains("no agent-ready actions")); - assert!(message.contains("`onedrive`")); - assert!(message.contains("`excel`")); - assert!(message.contains("`todoist`")); + assert!(message.contains("`sharepoint`")); + assert!(message.contains("`monday`")); + assert!(message.contains("`intercom`")); assert!(message.contains("curated agent tool catalogs")); } diff --git a/src/openhuman/composio/types.rs b/src/openhuman/composio/types.rs index 6be90a082..2e350820e 100644 --- a/src/openhuman/composio/types.rs +++ b/src/openhuman/composio/types.rs @@ -101,6 +101,17 @@ pub struct ComposioCapabilitiesResponse { pub capabilities: Vec, } +/// Response body of `composio.list_agent_ready_toolkits`. +/// +/// Sorted slugs that have a curated agent catalog — the frontend +/// uses this to decide whether to label a connected toolkit as +/// "preview / agent integration coming soon". See #2283. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ComposioAgentReadyToolkitsResponse { + #[serde(default)] + pub toolkits: Vec, +} + // ── Connections ───────────────────────────────────────────────────── /// One connected Composio account (OAuth integration instance).