From 91ecbe80c4e6fc7c1cc6af580fa391bf235fc245 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 24 Jun 2026 00:14:48 +0530 Subject: [PATCH] feat(composio): consume dynamic toolkit catalog from backend (#3933) --- app/src/components/composio/toolkitMeta.tsx | 77 ++++++- app/src/lib/composio/catalogCache.test.ts | 100 ++++++++ app/src/lib/composio/catalogCache.ts | 143 ++++++++++++ app/src/lib/composio/hooks.test.ts | 43 ++++ app/src/lib/composio/hooks.ts | 40 +++- app/src/lib/composio/types.ts | 35 +++ app/src/pages/Skills.tsx | 38 ++- .../Skills.composio-catalog.test.tsx | 35 +++ .../composio/connected_integrations.rs | 218 +++++++++++++++++- src/openhuman/composio/types.rs | 67 ++++++ 10 files changed, 762 insertions(+), 34 deletions(-) create mode 100644 app/src/lib/composio/catalogCache.test.ts create mode 100644 app/src/lib/composio/catalogCache.ts diff --git a/app/src/components/composio/toolkitMeta.tsx b/app/src/components/composio/toolkitMeta.tsx index 93d824b84..13cda81a1 100644 --- a/app/src/components/composio/toolkitMeta.tsx +++ b/app/src/components/composio/toolkitMeta.tsx @@ -14,6 +14,7 @@ import { type ReactNode, useState } from 'react'; import { canonicalizeComposioToolkitSlug } from '../../lib/composio/toolkitSlug'; +import type { ComposioToolkitCatalogEntry } from '../../lib/composio/types'; import type { SkillCategory } from '../skills/skillCategories'; export interface ComposioToolkitMeta { @@ -251,9 +252,18 @@ function GenericIntegrationIcon() { ); } -function ComposioLogoBadge({ slug, name }: { slug: string; name: string }) { +function ComposioLogoBadge({ + slug, + name, + logoUrl: logoOverride, +}: { + slug: string; + name: string; + /** Prefer the dynamic-catalog logo when present; fall back to the CDN URL. */ + logoUrl?: string; +}) { const [failed, setFailed] = useState(false); - const logoUrl = composioLogoUrl(slug); + const logoUrl = logoOverride || composioLogoUrl(slug); if (failed) { return ; @@ -285,6 +295,42 @@ function guessCategory(slug: string, name: string): SkillCategory { return 'Tools & Automation'; } +/** + * Map Composio's catalog category strings onto our fixed `SkillCategory` + * buckets. Composio category names are free-form (e.g. `"productivity"`, + * `"crm"`, `"developer-tools"`), so we match on substrings and return the + * first hit. Returns `undefined` when nothing matches so the caller can + * fall back to the slug/name keyword heuristic. + */ +function mapComposioCategory(categories?: string[]): SkillCategory | undefined { + if (!categories || categories.length === 0) return undefined; + const haystack = categories.join(' ').toLowerCase(); + const has = (...needles: string[]) => needles.some(n => haystack.includes(n)); + + if (has('chat', 'messaging', 'communication')) return 'Chat'; + if (has('social', 'marketing')) return 'Social'; + if ( + has( + 'productivity', + 'document', + 'calendar', + 'scheduling', + 'project management', + 'project-management', + 'note', + 'task', + 'storage', + 'email' + ) + ) { + return 'Productivity'; + } + if (has('crm', 'developer', 'devtool', 'analytics', 'payment', 'finance', 'database', 'cloud')) { + return 'Platform'; + } + return undefined; +} + function defaultDescription(name: string, category: SkillCategory): string { switch (category) { case 'Chat': @@ -342,17 +388,32 @@ function descriptionForToolkit(key: string, name: string, category: SkillCategor return defaultDescription(name, category); } -export function composioToolkitMeta(slug: string): ComposioToolkitMeta { +/** + * Build the render model for a toolkit card/modal. + * + * When a live-catalog `entry` is supplied (backend dynamic catalog — see + * `COMPOSIO_DYNAMIC_CATALOG_PLAN.md`) its name/description/category/logo + * win, so the UI reflects what Composio actually offers. Every field + * degrades gracefully to the local hardcoded derivation when the entry is + * absent (older core/backend) or a given field is empty. + */ +export function composioToolkitMeta( + slug: string, + entry?: ComposioToolkitCatalogEntry +): ComposioToolkitMeta { const key = canonicalizeComposioToolkitSlug(slug); - const name = MANAGED_TOOLKIT_NAME_BY_SLUG.get(key) ?? prettifyUnknownSlug(key); - const category = guessCategory(key, name); + const name = + entry?.name?.trim() || MANAGED_TOOLKIT_NAME_BY_SLUG.get(key) || prettifyUnknownSlug(key); + const category = mapComposioCategory(entry?.categories) ?? guessCategory(key, name); + const description = entry?.description?.trim() || descriptionForToolkit(key, name, category); + const logoUrl = entry?.logo?.trim() || composioLogoUrl(key); return { slug: key, name, - description: descriptionForToolkit(key, name, category), + description, category, - icon: , - logoUrl: composioLogoUrl(key), + icon: , + logoUrl, permissionLabel: permissionLabelFor(category), }; } diff --git a/app/src/lib/composio/catalogCache.test.ts b/app/src/lib/composio/catalogCache.test.ts new file mode 100644 index 000000000..a1e1e0c80 --- /dev/null +++ b/app/src/lib/composio/catalogCache.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getToolkitCatalog, invalidateToolkitCatalogCache } from './catalogCache'; + +const mockListToolkits = vi.fn(); + +vi.mock('./composioApi', () => ({ listToolkits: () => mockListToolkits() })); + +describe('catalogCache', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + window.localStorage.clear(); + invalidateToolkitCatalogCache(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fetches once then serves the cache within the TTL', async () => { + mockListToolkits.mockResolvedValue({ toolkits: ['gmail'], catalog: [] }); + + const first = await getToolkitCatalog(); + const second = await getToolkitCatalog(); + + expect(first.toolkits).toEqual(['gmail']); + expect(second.toolkits).toEqual(['gmail']); + expect(mockListToolkits).toHaveBeenCalledTimes(1); + }); + + it('dedupes concurrent callers into a single fetch', async () => { + let resolve: (v: unknown) => void = () => {}; + mockListToolkits.mockReturnValue( + new Promise(r => { + resolve = r; + }) + ); + + const a = getToolkitCatalog(); + const b = getToolkitCatalog(); + resolve({ toolkits: ['github'], catalog: [] }); + + await Promise.all([a, b]); + expect(mockListToolkits).toHaveBeenCalledTimes(1); + }); + + it('re-fetches after the cache is invalidated', async () => { + mockListToolkits.mockResolvedValue({ toolkits: ['gmail'], catalog: [] }); + + await getToolkitCatalog(); + invalidateToolkitCatalogCache(); + await getToolkitCatalog(); + + expect(mockListToolkits).toHaveBeenCalledTimes(2); + }); + + it('serves a stale cache when a refresh fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + mockListToolkits.mockResolvedValue({ toolkits: ['gmail'], catalog: [] }); + await getToolkitCatalog(); + + // Advance past the 24h TTL and make the refresh fail. + vi.setSystemTime(new Date('2026-01-03T00:00:00Z')); + mockListToolkits.mockRejectedValue(new Error('backend down')); + + const result = await getToolkitCatalog(); + expect(result.toolkits).toEqual(['gmail']); // stale, but better than erroring + }); + + it('propagates the error on a cold-cache failure', async () => { + mockListToolkits.mockRejectedValue(new Error('backend down')); + await expect(getToolkitCatalog()).rejects.toThrow('backend down'); + }); + + it('does not cache a response whose fetch was invalidated mid-flight', async () => { + // First fetch is in flight when the Composio client identity switches + // (mode toggle / BYO key → composio:config-changed → invalidate). + let resolveFirst: (v: unknown) => void = () => {}; + mockListToolkits.mockReturnValueOnce( + new Promise(r => { + resolveFirst = r; + }) + ); + + const inflightCall = getToolkitCatalog(); + invalidateToolkitCatalogCache(); // mid-flight: previous tenant's response is now stale + resolveFirst({ toolkits: ['old_tenant'], catalog: [] }); + await inflightCall; // the original caller still receives its response + + // The stale response must NOT have been written as a fresh 24h cache, so + // the next read issues a fresh RPC and serves the new tenant's catalog. + mockListToolkits.mockResolvedValueOnce({ toolkits: ['new_tenant'], catalog: [] }); + const next = await getToolkitCatalog(); + + expect(next.toolkits).toEqual(['new_tenant']); + expect(mockListToolkits).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/src/lib/composio/catalogCache.ts b/app/src/lib/composio/catalogCache.ts new file mode 100644 index 000000000..3232ebe52 --- /dev/null +++ b/app/src/lib/composio/catalogCache.ts @@ -0,0 +1,143 @@ +/** + * Client-side cache for the Composio toolkit catalog. + * + * Mirrors the backend's 24h cache (see the workspace + * `COMPOSIO_DYNAMIC_CATALOG_PLAN.md`). The catalog only changes when + * Composio adds/removes toolkits, so re-fetching it on every Skills-page + * mount is wasteful. We layer two guards in front of `listToolkits()`: + * + * 1. **In-flight dedupe** — N components mounting at once share a single + * RPC instead of firing one each. Race-free because the JS event loop + * never interleaves the synchronous check-then-assign below. + * 2. **localStorage TTL (24h)** — survives reloads and serves instantly + * on a warm cache; falls back to a live fetch when stale/absent. + * + * `invalidateToolkitCatalogCache()` clears both tiers — call it when the + * Composio client identity changes (backend ↔ direct mode, BYO API key), + * exactly like the existing `composio:config-changed` refresh path. + */ +import { listToolkits } from './composioApi'; +import type { ComposioToolkitsResponse } from './types'; + +const CACHE_KEY = 'composio:catalog:v1'; +const TTL_MS = 24 * 60 * 60 * 1000; + +interface CachedCatalog { + fetchedAt: number; + response: ComposioToolkitsResponse; +} + +/** Module-level in-flight promise so concurrent callers join one fetch. */ +let inflight: Promise | null = null; +/** In-memory mirror so we avoid a JSON.parse on the hot path. */ +let memory: CachedCatalog | null = null; +/** + * Bumped on every invalidation. A fetch captures the generation at start and + * refuses to write its result if the generation has since changed — so a + * response that was already in flight when the Composio client identity + * switched (mode toggle / BYO key → `composio:config-changed`) can't poison + * the cache with the *previous* tenant's catalog and have it served as fresh + * for 24h. + */ +let generation = 0; + +function readPersisted(): CachedCatalog | null { + if (memory) return memory; + try { + const raw = window.localStorage.getItem(CACHE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as CachedCatalog; + if ( + !parsed || + typeof parsed.fetchedAt !== 'number' || + !parsed.response || + !Array.isArray(parsed.response.toolkits) + ) { + return null; + } + memory = parsed; + return parsed; + } catch { + return null; + } +} + +function writePersisted(response: ComposioToolkitsResponse): void { + const entry: CachedCatalog = { fetchedAt: Date.now(), response }; + memory = entry; + try { + window.localStorage.setItem(CACHE_KEY, JSON.stringify(entry)); + } catch { + // Private-mode / quota errors are non-fatal — the in-memory mirror + // still serves this session. + } +} + +function isFresh(entry: CachedCatalog | null): boolean { + return entry !== null && Date.now() - entry.fetchedAt < TTL_MS; +} + +/** + * Resolve the toolkit catalog, preferring a fresh client cache. + * + * - Fresh cache (< 24h) → returned immediately, no RPC. + * - Stale-but-present + fetch ok → fresh value, cache refreshed. + * - Stale-but-present + fetch fail → stale value served (graceful degrade). + * - Cold + fetch fail → error propagates to the caller. + */ +export async function getToolkitCatalog(): Promise { + const cached = readPersisted(); + if (cached && isFresh(cached)) return cached.response; + + if (inflight) return inflight; + // Snapshot the generation so a mid-flight invalidation makes this fetch's + // result non-authoritative (see `generation`). + const startGeneration = generation; + const fetchPromise = listToolkits() + .then(response => { + // Only cache the response if no invalidation happened while it was in + // flight; otherwise it belongs to a stale tenant. Still return it to + // this caller — just don't poison the shared cache for future reads. + if (generation === startGeneration) { + writePersisted(response); + } else { + console.debug('[composio-cache] discarding catalog response invalidated mid-flight'); + } + return response; + }) + .catch(err => { + // On failure, fall back to a stale cache if we have one rather than + // forcing the UI into an error state for a list that rarely changes. + // Skip the fallback if we were invalidated mid-flight — the cached + // value belongs to the previous tenant. + if (cached && generation === startGeneration) { + console.warn( + '[composio-cache] catalog fetch failed; serving stale cache:', + err instanceof Error ? err.message : String(err) + ); + return cached.response; + } + throw err; + }) + .finally(() => { + // Only clear the slot if it's still ours — an invalidation may have + // reset `inflight` to null and a newer fetch taken its place. + if (inflight === fetchPromise) { + inflight = null; + } + }); + inflight = fetchPromise; + return fetchPromise; +} + +/** Drop both cache tiers so the next read re-fetches. */ +export function invalidateToolkitCatalogCache(): void { + generation += 1; + memory = null; + inflight = null; + try { + window.localStorage.removeItem(CACHE_KEY); + } catch { + // ignore + } +} diff --git a/app/src/lib/composio/hooks.test.ts b/app/src/lib/composio/hooks.test.ts index 139131234..c35fdb71b 100644 --- a/app/src/lib/composio/hooks.test.ts +++ b/app/src/lib/composio/hooks.test.ts @@ -29,6 +29,9 @@ describe('useComposioIntegrations', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + // The toolkit catalog is now cached in localStorage (24h TTL); clear it + // so each test exercises the mocked fetch instead of a prior test's cache. + window.localStorage.clear(); sessionToken = 'jwt-abc'; mockOpenhumanComposioGetMode.mockResolvedValue({ result: { mode: 'backend', api_key_set: true }, @@ -54,6 +57,46 @@ describe('useComposioIntegrations', () => { expect(result.current.error).toBe('backend connection listing failed'); }); + it('exposes the dynamic catalog keyed by canonical slug', async () => { + const { useComposioIntegrations } = await import('./hooks'); + + mockListToolkits.mockResolvedValue({ + toolkits: ['gmail', 'googlecalendar'], + catalog: [ + { slug: 'gmail', name: 'Gmail', logo: 'https://x/gmail.png', enabled: true }, + // Alias slug must be canonicalized to googlecalendar. + { slug: 'google_calendar', name: 'Google Calendar', enabled: true }, + ], + }); + mockListConnections.mockResolvedValue({ connections: [] }); + + const { result } = renderHook(() => useComposioIntegrations(0)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.catalogByToolkit.get('gmail')?.name).toBe('Gmail'); + expect(result.current.catalogByToolkit.get('gmail')?.logo).toBe('https://x/gmail.png'); + expect(result.current.catalogByToolkit.get('googlecalendar')?.name).toBe('Google Calendar'); + }); + + it('leaves the catalog empty when the core omits it (back-compat)', async () => { + const { useComposioIntegrations } = await import('./hooks'); + + mockListToolkits.mockResolvedValue({ toolkits: ['gmail'] }); + mockListConnections.mockResolvedValue({ connections: [] }); + + const { result } = renderHook(() => useComposioIntegrations(0)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.toolkits).toEqual(['gmail']); + expect(result.current.catalogByToolkit.size).toBe(0); + }); + it('groups connections by toolkit, sorts by status then createdAt', async () => { const { useComposioIntegrations } = await import('./hooks'); diff --git a/app/src/lib/composio/hooks.ts b/app/src/lib/composio/hooks.ts index 7967dbecd..87d1f10f6 100644 --- a/app/src/lib/composio/hooks.ts +++ b/app/src/lib/composio/hooks.ts @@ -3,15 +3,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { isLocalSessionToken } from '../../utils/localSession'; import { openhumanComposioGetMode } from '../../utils/tauriCommands'; import { getCoreStateSnapshot } from '../coreState/store'; -import { listAgentReadyToolkits, listConnections, listToolkits } from './composioApi'; +import { getToolkitCatalog, invalidateToolkitCatalogCache } from './catalogCache'; +import { listAgentReadyToolkits, listConnections } from './composioApi'; import { canonicalizeComposioToolkitSlug } from './toolkitSlug'; -import type { ComposioConnection } from './types'; +import type { ComposioConnection, ComposioToolkitCatalogEntry } from './types'; // ── useComposioIntegrations ─────────────────────────────────────── export interface UseComposioIntegrationsResult { /** Toolkit slugs enabled on the backend allowlist. */ toolkits: string[]; + /** + * Live Composio catalog entries (dynamic name/logo/description/ + * categories) keyed by canonical lowercased slug. Empty when the + * core/backend predates the dynamic catalog — consumers then fall + * back to the local `toolkitMeta` derivation. + */ + catalogByToolkit: Map; /** Best (highest-status) connection keyed by lowercased toolkit slug. */ connectionByToolkit: Map; /** All connections keyed by lowercased toolkit slug, sorted by status (ACTIVE first, then by createdAt). */ @@ -40,6 +48,7 @@ export interface UseComposioIntegrationsResult { export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioIntegrationsResult { const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken); const [toolkits, setToolkits] = useState([]); + const [catalog, setCatalog] = useState([]); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -80,6 +89,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte if (!enabled) { if (mountedRef.current) { setToolkits([]); + setCatalog([]); setConnections([]); setError(null); setLoading(false); @@ -90,13 +100,14 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte let nextError: string | null = null; try { const [toolkitsResult, connectionsResult] = await Promise.allSettled([ - listToolkits(), + getToolkitCatalog(), listConnections(), ]); if (!mountedRef.current) return; if (toolkitsResult.status === 'fulfilled') { setToolkits(toolkitsResult.value.toolkits ?? []); + setCatalog(toolkitsResult.value.catalog ?? []); } else { const message = toolkitsResult.reason instanceof Error @@ -155,6 +166,10 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte useEffect(() => { const onConfigChanged = () => { console.debug('[composio-cache] window:composio:config-changed → refresh()'); + // The Composio client identity changed (backend ↔ direct / BYO key), + // so the cached catalog belongs to the previous tenant. Drop it before + // refetching, mirroring the core-side ComposioConfigChanged eviction. + invalidateToolkitCatalogCache(); if (isLocalSession) { void resolveFetchEnabled().then(enabled => { if (enabled) { @@ -163,6 +178,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte } if (mountedRef.current) { setToolkits([]); + setCatalog([]); setConnections([]); setError(null); setLoading(false); @@ -184,6 +200,14 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte return 0; }; + const catalogByToolkit = useMemo(() => { + const map = new Map(); + for (const entry of catalog) { + map.set(canonicalizeComposioToolkitSlug(entry.slug), entry); + } + return map; + }, [catalog]); + const connectionByToolkit = useMemo(() => { const map = new Map(); for (const conn of connections) { @@ -215,7 +239,15 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte return map; }, [connections]); - return { toolkits, connectionByToolkit, connectionsByToolkit, loading, error, refresh }; + return { + toolkits, + catalogByToolkit, + connectionByToolkit, + connectionsByToolkit, + loading, + error, + refresh, + }; } // ── useAgentReadyComposioToolkits ───────────────────────────────── diff --git a/app/src/lib/composio/types.ts b/app/src/lib/composio/types.ts index 0f09b6467..bb0323a23 100644 --- a/app/src/lib/composio/types.ts +++ b/app/src/lib/composio/types.ts @@ -5,8 +5,43 @@ * backend emits camelCase, snake_case where the Rust RPC layer does). */ +/** + * One toolkit as described by the live Composio catalog. The backend + * fetches this from `composio.toolkits.get({})` (see the workspace + * `COMPOSIO_DYNAMIC_CATALOG_PLAN.md`), caches it, and forwards it through + * the core so the desktop UI no longer has to hardcode display metadata. + * + * Everything except `slug` is best-effort: older cores/backends that + * predate the dynamic catalog omit `catalog` entirely, so consumers must + * fall back to the local `toolkitMeta` derivation. + */ +export interface ComposioToolkitCatalogEntry { + /** Toolkit slug as Composio emits it, e.g. `"googlecalendar"`. */ + slug: string; + /** Human-readable name, e.g. `"Google Calendar"`. */ + name: string; + /** Composio-hosted logo URL (`meta.logo`). */ + logo?: string; + /** Short description (`meta.description`). */ + description?: string; + /** Composio category names (`meta.categories[].name`/slug). */ + categories?: string[]; + /** + * Whether the user can actually connect/use this toolkit right now — + * i.e. it passed the backend curation/allowlist gate. Uncurated + * toolkits still appear in the catalog but render as preview. + */ + enabled?: boolean; +} + export interface ComposioToolkitsResponse { + /** Back-compat: slugs the user is allowed to connect (enabled only). */ toolkits: string[]; + /** + * Rich render model from the dynamic Composio catalog. Optional — + * cores that predate the dynamic catalog send only `toolkits`. + */ + catalog?: ComposioToolkitCatalogEntry[]; } /** diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 22098915b..1b4ada909 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -462,6 +462,9 @@ export default function Skills() { const { toolkits: composioToolkits, + // Default to an empty map so the component is resilient when a test + // mock (or an older hook build) omits the dynamic-catalog field. + catalogByToolkit: composioCatalogByToolkit = new Map(), connectionByToolkit: composioConnectionByToolkit, connectionsByToolkit: composioConnectionsByToolkit, error: composioError, @@ -538,21 +541,34 @@ export default function Skills() { const composioCatalogToolkits = useMemo(() => { const normalizedToolkits = composioToolkits.map(slug => canonicalizeComposioToolkitSlug(slug)); - const missingKnownToolkits = KNOWN_COMPOSIO_TOOLKITS.filter( - slug => !normalizedToolkits.includes(slug) - ); - if (IS_DEV && missingKnownToolkits.length > 0) { - console.debug('[skills][composio] filling gaps from KNOWN_COMPOSIO_TOOLKITS', { + // Prefer the live Composio catalog when the backend supplies it. The + // hardcoded KNOWN_COMPOSIO_TOOLKITS list only fills gaps for older + // cores that predate the dynamic catalog (see + // COMPOSIO_DYNAMIC_CATALOG_PLAN.md) so the grid is never empty. + const dynamicSlugs = Array.from(composioCatalogByToolkit.keys()); + const hasDynamicCatalog = dynamicSlugs.length > 0; + const baseSlugs = hasDynamicCatalog ? dynamicSlugs : KNOWN_COMPOSIO_TOOLKITS; + + if (IS_DEV) { + const missingKnownToolkits = KNOWN_COMPOSIO_TOOLKITS.filter( + slug => !normalizedToolkits.includes(slug) + ); + console.debug('[skills][composio] building catalog', { + source: hasDynamicCatalog ? 'dynamic-backend' : 'hardcoded-fallback', + dynamicCount: dynamicSlugs.length, toolkitCount: composioToolkits.length, connectionCount: composioConnectionByToolkit.size, hasError: Boolean(composioError), - missingKnownToolkits, + missingKnownToolkits: hasDynamicCatalog ? [] : missingKnownToolkits, }); } - return Array.from(new Set([...KNOWN_COMPOSIO_TOOLKITS, ...normalizedToolkits])).sort((a, b) => + + // Union base slugs with enabled slugs and any connected toolkit so a + // connection always renders even if it's missing from the catalog. + return Array.from(new Set([...baseSlugs, ...normalizedToolkits])).sort((a, b) => a.localeCompare(b) ); - }, [composioToolkits, composioConnectionByToolkit, composioError]); + }, [composioToolkits, composioCatalogByToolkit, composioConnectionByToolkit, composioError]); // Unified item list const allItems: SkillItem[] = useMemo(() => { @@ -600,12 +616,14 @@ export default function Skills() { connection: ComposioConnection | undefined; }> = []; for (const slug of composioCatalogToolkits) { - const meta = composioToolkitMeta(slug); + const canonical = canonicalizeComposioToolkitSlug(slug); + const entry = composioCatalogByToolkit.get(canonical); + const meta = composioToolkitMeta(slug, entry); const connection = composioConnectionByToolkit.get(meta.slug); entries.push({ meta, connection }); } return entries; - }, [composioCatalogToolkits, composioConnectionByToolkit]); + }, [composioCatalogToolkits, composioCatalogByToolkit, composioConnectionByToolkit]); const composioFilteredEntries = useMemo(() => { const q = searchQuery.toLowerCase(); diff --git a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx index c47e0fbd7..c13a3040c 100644 --- a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx +++ b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx @@ -8,6 +8,7 @@ import Skills from '../Skills'; let composioRefresh = vi.fn(); let composioError: string | null = null; let composioToolkits: string[] = []; +let composioCatalogByToolkit = new Map(); let composioConnectionByToolkit = new Map(); let composioConnectionsByToolkitOverride: Map | null = null; let sessionToken = 'jwt-abc'; @@ -37,6 +38,7 @@ vi.mock('../../lib/skills/hooks', () => ({ vi.mock('../../lib/composio/hooks', () => ({ useComposioIntegrations: () => ({ toolkits: composioToolkits, + catalogByToolkit: composioCatalogByToolkit, connectionByToolkit: composioConnectionByToolkit, connectionsByToolkit: composioConnectionsByToolkitOverride ?? @@ -71,6 +73,7 @@ describe('Skills page — Composio catalog fallback', () => { composioRefresh = vi.fn(); composioError = null; composioToolkits = []; + composioCatalogByToolkit = new Map(); composioConnectionByToolkit = new Map(); composioConnectionsByToolkitOverride = null; sessionToken = 'jwt-abc'; @@ -107,6 +110,38 @@ describe('Skills page — Composio catalog fallback', () => { expect(screen.queryByRole('heading', { name: 'Other' })).not.toBeInTheDocument(); }); + it('renders integrations from the live dynamic catalog when the backend provides one', () => { + // When the backend ships the dynamic catalog, the grid is sourced from + // it (names/categories from the entry), not the hardcoded fallback list. + composioCatalogByToolkit = new Map([ + [ + 'acme_crm', + { slug: 'acme_crm', name: 'Acme CRM Dynamic', categories: ['crm'], enabled: true }, + ], + [ + 'gmail', + { slug: 'gmail', name: 'Gmail Dynamic', categories: ['productivity'], enabled: true }, + ], + ]); + + renderWithProviders(, { initialEntries: ['/connections'] }); + openAppsTab(); + + const integrationsSection = screen.getByTestId('composio-integrations-card'); + // Names come from the dynamic catalog entry, overriding local metadata. + expect( + within(integrationsSection as HTMLElement).getByText('Acme CRM Dynamic') + ).toBeInTheDocument(); + expect( + within(integrationsSection as HTMLElement).getByText('Gmail Dynamic') + ).toBeInTheDocument(); + // A hardcoded-only toolkit absent from the dynamic catalog must NOT + // appear — proving the grid is driven by the backend, not KNOWN_*. + expect( + within(integrationsSection as HTMLElement).queryByText('Discord') + ).not.toBeInTheDocument(); + }); + it('shows a stale/error state instead of disconnected toolkits when composio loading fails', () => { composioError = 'Backend unavailable'; diff --git a/src/openhuman/composio/connected_integrations.rs b/src/openhuman/composio/connected_integrations.rs index d18902f8f..695fd4fc7 100644 --- a/src/openhuman/composio/connected_integrations.rs +++ b/src/openhuman/composio/connected_integrations.rs @@ -415,11 +415,70 @@ pub async fn fetch_connected_integrations_status( /// /// Returns `None` when we couldn't even build a client (no auth), /// signalling the caller should NOT cache this result. +/// The connectable toolkit slugs to surface in the agent prompt, aligned +/// with the backend's execution gate. +/// +/// Prefers the dynamic catalog's **enabled** entries (openhuman PR #3933 / +/// backend #1012). The backend gate (`isToolkitConnectable` → +/// `getProjectList().filter(p => p.enabled)`) and `catalog[].enabled` are +/// driven by the same project auth-config status, so sourcing membership from +/// `catalog.filter(enabled)` pins the prompt's advertised set to exactly what +/// connect/authorize/execute will actually allow — it can't drift even if the +/// backend later changes how the flat `toolkits` array is projected. +/// +/// Falls back to the back-compat `toolkits` array when the catalog is absent +/// (backends predating the dynamic catalog send only `toolkits`); without the +/// fallback, membership against an old core would collapse to empty and the +/// agent would lose every integration. Disabled catalog entries are dropped +/// because the gate would reject them — advertising them would only invite +/// failed delegations. Slugs are trimmed + lowercased to match downstream +/// canonicalisation. +fn connectable_toolkit_slugs( + toolkits: &[String], + catalog: &[super::types::ComposioToolkitCatalogEntry], +) -> Vec { + let normalize = |s: &str| s.trim().to_ascii_lowercase(); + if catalog.is_empty() { + toolkits + .iter() + .map(|t| normalize(t)) + .filter(|t| !t.is_empty()) + .collect() + } else { + catalog + .iter() + .filter(|entry| entry.enabled.unwrap_or(false)) + .map(|entry| normalize(&entry.slug)) + .filter(|slug| !slug.is_empty()) + .collect() + } +} + +/// Choose the one-line description rendered for a toolkit in the agent +/// prompt's `## Connected Integrations` block. +/// +/// Prefers the backend's **dynamic catalog** description (openhuman PR #3933 +/// / backend #1012 — `GET /agent-integrations/composio/toolkits` now returns +/// a `catalog[]` with per-toolkit metadata) so the orchestrator advertises +/// what Composio actually offers. Falls back to the hardcoded +/// `toolkit_description` table when the catalog omits the toolkit or ships an +/// empty description — i.e. older backends that predate the dynamic catalog, +/// or project toolkits whose Composio metadata join produced no blurb. Keyed +/// by lowercased slug to match the canonicalised allowlist. +fn resolve_toolkit_description( + catalog_descriptions: &std::collections::HashMap, + slug: &str, +) -> String { + catalog_descriptions + .get(slug) + .cloned() + .unwrap_or_else(|| super::providers::toolkit_description(slug).to_string()) +} + async fn fetch_connected_integrations_uncached( config: &Config, ) -> Option> { use super::client::{create_composio_client, direct_list_connections, ComposioClientKind}; - use super::providers::toolkit_description; // Route via the mode-aware factory so the chat-agent's // "connected_integrations" view reflects the live tenant — backend @@ -456,19 +515,52 @@ async fn fetch_connected_integrations_uncached( // integration from the orchestrator until the process restarts or // the cache is explicitly invalidated — a single 5xx during // startup would silently break delegation for the whole session. - let (allowlisted_toolkits, connections, tools_by_toolkit): ( + let (allowlisted_toolkits, connections, tools_by_toolkit, catalog_descriptions): ( Vec, Vec, Vec, + std::collections::HashMap, ) = match &kind { ComposioClientKind::Backend(client) => { - let allowlist: Vec = match client.list_toolkits().await { - Ok(resp) => resp - .toolkits - .into_iter() - .map(|toolkit| toolkit.trim().to_ascii_lowercase()) - .filter(|toolkit| !toolkit.is_empty()) - .collect(), + let (allowlist, catalog_descriptions): ( + Vec, + std::collections::HashMap, + ) = match client.list_toolkits().await { + Ok(resp) => { + // Index the dynamic catalog's descriptions by lowercased + // slug so the prompt builder can prefer them over the + // hardcoded table (see `resolve_toolkit_description`). + // Empty descriptions are skipped so the fallback applies. + let catalog_descriptions: std::collections::HashMap = resp + .catalog + .iter() + .filter_map(|entry| { + let slug = entry.slug.trim().to_ascii_lowercase(); + if slug.is_empty() { + return None; + } + let desc = entry + .description + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty())?; + Some((slug, desc.to_string())) + }) + .collect(); + // Source the connectable set from the catalog's enabled + // entries (the gate's own predicate), falling back to the + // flat `toolkits` array for pre-catalog backends. See + // `connectable_toolkit_slugs`. + let allowlist = connectable_toolkit_slugs(&resp.toolkits, &resp.catalog); + tracing::debug!( + catalog_entries = resp.catalog.len(), + catalog_descriptions = catalog_descriptions.len(), + connectable = allowlist.len(), + catalog_sourced = !resp.catalog.is_empty(), + "[composio] fetch_connected_integrations: resolved connectable set from catalog" + ); + (allowlist, catalog_descriptions) + } Err(e) => { tracing::warn!( "[composio] fetch_connected_integrations: list_toolkits (backend) failed: {e}" @@ -524,7 +616,7 @@ async fn fetch_connected_integrations_uncached( } }; - (allowlist, connections, tools) + (allowlist, connections, tools, catalog_descriptions) } ComposioClientKind::Direct(direct) => { // Direct mode: walk the user's personal Composio tenant @@ -605,7 +697,14 @@ async fn fetch_connected_integrations_uncached( Vec::new() } }; - (allowlist, connections, tools) + // Direct mode has no central catalog endpoint, so prompt + // descriptions come from the hardcoded fallback table. + ( + allowlist, + connections, + tools, + std::collections::HashMap::new(), + ) } }; @@ -821,7 +920,7 @@ async fn fetch_connected_integrations_uncached( integrations.push(ConnectedIntegration { toolkit: slug.clone(), - description: toolkit_description(slug).to_string(), + description: resolve_toolkit_description(&catalog_descriptions, slug), tools, gated_tools, connected, @@ -918,3 +1017,98 @@ pub async fn fetch_toolkit_actions( ); Ok(actions) } + +#[cfg(test)] +mod connectable_slug_tests { + use super::connectable_toolkit_slugs; + use crate::openhuman::composio::types::ComposioToolkitCatalogEntry; + + fn entry(slug: &str, enabled: bool) -> ComposioToolkitCatalogEntry { + ComposioToolkitCatalogEntry { + slug: slug.to_string(), + enabled: Some(enabled), + ..Default::default() + } + } + + #[test] + fn prefers_catalog_enabled_entries_and_drops_disabled() { + // Disabled entries are excluded (the gate would reject them); the + // flat `toolkits` array is ignored when a catalog is present. + let catalog = vec![ + entry("gmail", true), + entry("notion", false), + entry("GitHub", true), // uppercase slug normalised + ]; + let toolkits = vec!["ignored_when_catalog_present".to_string()]; + assert_eq!( + connectable_toolkit_slugs(&toolkits, &catalog), + vec!["gmail".to_string(), "github".to_string()] + ); + } + + #[test] + fn falls_back_to_toolkits_when_catalog_empty() { + // Older backends send only `toolkits`; membership must still resolve. + let toolkits = vec!["Gmail".to_string(), " ".to_string()]; + assert_eq!( + connectable_toolkit_slugs(&toolkits, &[]), + vec!["gmail".to_string()] + ); + } + + #[test] + fn enabled_none_is_treated_as_not_connectable() { + // Defensive: an entry without an explicit `enabled` is excluded, + // matching the gate's strict `enabled === true` check. + let catalog = vec![ComposioToolkitCatalogEntry { + slug: "mystery".to_string(), + enabled: None, + ..Default::default() + }]; + assert!(connectable_toolkit_slugs(&[], &catalog).is_empty()); + } +} + +#[cfg(test)] +mod catalog_description_tests { + use super::resolve_toolkit_description; + use std::collections::HashMap; + + #[test] + fn prefers_dynamic_catalog_description_when_present() { + let mut catalog = HashMap::new(); + catalog.insert( + "gmail".to_string(), + "Live Gmail blurb from the Composio catalog".to_string(), + ); + assert_eq!( + resolve_toolkit_description(&catalog, "gmail"), + "Live Gmail blurb from the Composio catalog" + ); + } + + #[test] + fn falls_back_to_hardcoded_table_when_catalog_omits_toolkit() { + // Empty catalog (older backend / no metadata) → hardcoded fallback. + let catalog = HashMap::new(); + let got = resolve_toolkit_description(&catalog, "gmail"); + assert_eq!( + got, + crate::openhuman::composio::providers::toolkit_description("gmail") + ); + assert!(!got.is_empty()); + } + + #[test] + fn falls_back_when_a_different_toolkit_is_catalogued() { + // Catalog has an entry, but not for the slug we're rendering — the + // fallback must still apply per-slug, not globally. + let mut catalog = HashMap::new(); + catalog.insert("notion".to_string(), "Notion from catalog".to_string()); + assert_eq!( + resolve_toolkit_description(&catalog, "gmail"), + crate::openhuman::composio::providers::toolkit_description("gmail") + ); + } +} diff --git a/src/openhuman/composio/types.rs b/src/openhuman/composio/types.rs index 3da35db38..21249f217 100644 --- a/src/openhuman/composio/types.rs +++ b/src/openhuman/composio/types.rs @@ -63,12 +63,45 @@ fn de_opt_string_or_object<'de, D: Deserializer<'de>>(d: D) -> Result, + /// Short description (`meta.description`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Composio category names (`meta.categories`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub categories: Vec, + /// Whether the user can connect/use this toolkit (passed the backend gate). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + /// Response body of `GET /agent-integrations/composio/toolkits`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposioToolkitsResponse { /// Server-enforced toolkit allowlist, e.g. `["gmail", "notion"]`. #[serde(default)] pub toolkits: Vec, + /// Rich render model from the live Composio catalog. Optional — empty when + /// the backend predates the dynamic catalog. Forwarded as-is to the UI. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub catalog: Vec, } /// One row in OpenHuman's local Composio capability matrix. @@ -490,11 +523,45 @@ mod tests { fn toolkits_response_roundtrips() { let resp = ComposioToolkitsResponse { toolkits: vec!["gmail".into(), "notion".into()], + ..Default::default() }; let value = serde_json::to_value(&resp).unwrap(); + // Empty catalog is skipped on the wire — back-compat with old cores. assert_eq!(value, json!({ "toolkits": ["gmail", "notion"] })); let back: ComposioToolkitsResponse = serde_json::from_value(value).unwrap(); assert_eq!(back.toolkits, vec!["gmail", "notion"]); + assert!(back.catalog.is_empty()); + } + + #[test] + fn toolkits_response_forwards_catalog() { + // A backend that sends the dynamic catalog must deserialize and + // re-serialize verbatim so the field reaches the desktop UI. + let raw = json!({ + "toolkits": ["gmail"], + "catalog": [ + { + "slug": "gmail", + "name": "Gmail", + "logo": "https://logos.composio.dev/api/gmail", + "description": "Send and read email", + "categories": ["productivity"], + "enabled": true + } + ] + }); + let resp: ComposioToolkitsResponse = serde_json::from_value(raw).unwrap(); + assert_eq!(resp.catalog.len(), 1); + let entry = &resp.catalog[0]; + assert_eq!(entry.slug, "gmail"); + assert_eq!(entry.name, "Gmail"); + assert_eq!(entry.enabled, Some(true)); + assert_eq!(entry.categories, vec!["productivity".to_string()]); + + // Round-trips back out with the catalog intact. + let value = serde_json::to_value(&resp).unwrap(); + assert_eq!(value["catalog"][0]["slug"], "gmail"); + assert_eq!(value["catalog"][0]["enabled"], true); } #[test]