mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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 <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
Steven Enamakel
parent
2c68eae9ad
commit
e9c9374313
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ComposioToolkitsResponse> {
|
||||
return unwrapCliEnvelope<ComposioToolkitsResponse>(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<ComposioAgentReadyToolkitsResponse> {
|
||||
const raw = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.composio_list_agent_ready_toolkits',
|
||||
});
|
||||
return unwrapCliEnvelope<ComposioAgentReadyToolkitsResponse>(raw);
|
||||
}
|
||||
|
||||
export async function listConnections(): Promise<ComposioConnectionsResponse> {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.composio_list_connections' });
|
||||
return unwrapCliEnvelope<ComposioConnectionsResponse>(raw);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>;
|
||||
/** 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<ReadonlySet<string>>(() => new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'جارٍ التحميل…',
|
||||
|
||||
@@ -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': 'লোড হচ্ছে…',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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': 'अभी तक रूट नहीं हुआ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '아직 라우팅되지 않음',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Загрузка…',
|
||||
|
||||
@@ -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': '加载中…',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
}`}>
|
||||
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center text-stone-700 dark:text-neutral-200 [&_img]:max-h-10 [&_img]:max-w-10 [&_svg]:h-8 [&_svg]:w-8">
|
||||
{meta.icon}
|
||||
{showPreviewBadge && (
|
||||
<span
|
||||
data-testid={`composio-preview-badge-${meta.slug}`}
|
||||
className="absolute -top-1 -right-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[8px] font-semibold uppercase leading-none text-amber-800 ring-1 ring-amber-300 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-amber-500/40">
|
||||
{previewLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 flex-col items-center justify-start gap-0.5">
|
||||
<span className="line-clamp-2 text-[11px] font-semibold leading-tight text-stone-900 dark:text-neutral-100">
|
||||
@@ -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<ChannelDefinition | null>(null);
|
||||
const [composioModalToolkit, setComposioModalToolkit] = useState<ComposioToolkitMeta | null>(
|
||||
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()}
|
||||
|
||||
@@ -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<string>(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Skills page — Channels grid', () => {
|
||||
|
||||
@@ -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<string>; loading: boolean; error: string | null } = {
|
||||
agentReady: new Set<string>(),
|
||||
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<string>(), 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<string>(), loading: false, error: 'rpc unavailable' };
|
||||
|
||||
renderWithProviders(<Skills />, { 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-<slug>`; absence means we degraded
|
||||
// gracefully on RPC failure.
|
||||
const previewBadges = within(integrationsSection as HTMLElement).queryAllByTestId(
|
||||
/composio-preview-badge-/
|
||||
);
|
||||
expect(previewBadges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,12 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
// Issue #2283: Skills.tsx also consumes useAgentReadyComposioToolkits.
|
||||
useAgentReadyComposioToolkits: () => ({
|
||||
agentReady: new Set<string>(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Skills page — discovered skill cards', () => {
|
||||
|
||||
@@ -27,6 +27,12 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
// Issue #2283: Skills.tsx also consumes useAgentReadyComposioToolkits.
|
||||
useAgentReadyComposioToolkits: () => ({
|
||||
agentReady: new Set<string>(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Skills page — Gmail composio integration', () => {
|
||||
|
||||
@@ -25,6 +25,12 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
// Issue #2283: Skills.tsx also consumes useAgentReadyComposioToolkits.
|
||||
useAgentReadyComposioToolkits: () => ({
|
||||
agentReady: new Set<string>(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Skills page — Notion composio integration', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RpcOutcome<super::types::ComposioAgentReadyToolkitsResponse>> {
|
||||
tracing::debug!("[composio] rpc list_agent_ready_toolkits");
|
||||
let toolkits: Vec<String> = 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(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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/<id>.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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64> {
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ControllerSchema> {
|
||||
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<RegisteredController> {
|
||||
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<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_agent_ready_toolkits(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(super::ops::composio_list_agent_ready_toolkits().await?) })
|
||||
}
|
||||
|
||||
fn handle_list_connections(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,17 @@ pub struct ComposioCapabilitiesResponse {
|
||||
pub capabilities: Vec<ComposioCapability>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
// ── Connections ─────────────────────────────────────────────────────
|
||||
|
||||
/// One connected Composio account (OAuth integration instance).
|
||||
|
||||
Reference in New Issue
Block a user