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:
CodeGhost21
2026-05-22 19:55:57 -07:00
committed by GitHub
co-authored by Steven Enamakel Steven Enamakel
parent 2c68eae9ad
commit e9c9374313
35 changed files with 875 additions and 16 deletions
+29
View File
@@ -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']);
});
});
+19
View File
@@ -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);
+65
View File
@@ -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();
});
});
+61 -1
View File
@@ -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 };
}
+11
View File
@@ -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;
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey': 'فشل الحفظ. الوضع المباشر يتطلب مفتاح API غير فارغ.',
'composio.notYetRouted': 'لم يتم توجيهه بعد',
'composio.triggers.loading': 'جارٍ التحميل…',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey': 'সংরক্ষণ ব্যর্থ। Direct মোডের জন্য একটি API key প্রয়োজন।',
'composio.notYetRouted': 'এখনও রুট করা হয়নি',
'composio.triggers.loading': 'লোড হচ্ছে…',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey':
'Speichern fehlgeschlagen. Der Direktmodus erfordert einen nicht leeren Schlüssel API.',
'composio.notYetRouted': 'noch nicht geroutet',
+3
View File
@@ -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 cant 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…',
+3
View File
@@ -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 cant 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',
+3
View File
@@ -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 cant 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é',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey':
'सहेजने में विफल। डायरेक्ट मोड के लिए गैर-रिक्त API कुंजी आवश्यक है।',
'composio.notYetRouted': 'अभी तक रूट नहीं हुआ',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey':
'Gagal menyimpan. Mode Direct memerlukan API key yang tidak kosong.',
'composio.notYetRouted': 'belum dirutekan',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey':
'Salvataggio fallito. La modalità Diretta richiede una chiave API non vuota.',
'composio.notYetRouted': 'non ancora instradato',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey':
'저장에 실패했습니다. Direct 모드에는 비어 있지 않은 API 키가 필요합니다.',
'composio.notYetRouted': '아직 라우팅되지 않음',
+3
View File
@@ -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 cant 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',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey': 'Не удалось сохранить. Прямой режим требует непустой API-ключ.',
'composio.notYetRouted': 'пока не маршрутизируется',
'composio.triggers.loading': 'Загрузка…',
+3
View File
@@ -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 cant use this toolkit yet.',
'composio.directModeRequiresKey': '保存失败。直连模式需要非空的 API 密钥。',
'composio.notYetRouted': '尚未路由',
'composio.triggers.loading': '加载中…',
+3
View File
@@ -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 cant use this toolkit yet.',
// Memory: day-of-week labels for heatmap
'memory.day.sun': 'Sun',
+55 -3
View File
@@ -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', () => {
+2 -2
View File
@@ -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,
+24 -1
View File
@@ -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(
+4 -2
View File
@@ -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",
}
}
+115
View File
@@ -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();
+25
View File
@@ -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?;
+9 -6
View File
@@ -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"));
}
+11
View File
@@ -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).