feat(composio): consume dynamic toolkit catalog from backend (#3933)

This commit is contained in:
sanil-23
2026-06-23 11:44:48 -07:00
committed by GitHub
parent ec98cca9d4
commit 91ecbe80c4
10 changed files with 762 additions and 34 deletions
+69 -8
View File
@@ -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 <GenericIntegrationIcon />;
@@ -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: <ComposioLogoBadge slug={key} name={name} />,
logoUrl: composioLogoUrl(key),
icon: <ComposioLogoBadge slug={key} name={name} logoUrl={logoUrl} />,
logoUrl,
permissionLabel: permissionLabelFor(category),
};
}
+100
View File
@@ -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);
});
});
+143
View File
@@ -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<ComposioToolkitsResponse> | 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<ComposioToolkitsResponse> {
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
}
}
+43
View File
@@ -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');
+36 -4
View File
@@ -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<string, ComposioToolkitCatalogEntry>;
/** Best (highest-status) connection keyed by lowercased toolkit slug. */
connectionByToolkit: Map<string, ComposioConnection>;
/** 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<string[]>([]);
const [catalog, setCatalog] = useState<ComposioToolkitCatalogEntry[]>([]);
const [connections, setConnections] = useState<ComposioConnection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, ComposioToolkitCatalogEntry>();
for (const entry of catalog) {
map.set(canonicalizeComposioToolkitSlug(entry.slug), entry);
}
return map;
}, [catalog]);
const connectionByToolkit = useMemo(() => {
const map = new Map<string, ComposioConnection>();
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 ─────────────────────────────────
+35
View File
@@ -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[];
}
/**
+28 -10
View File
@@ -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();
@@ -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<string, unknown[]> | 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(<Skills />, { 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';