mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(composio): consume dynamic toolkit catalog from backend (#3933)
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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<String, String>,
|
||||
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<Vec<ConnectedIntegration>> {
|
||||
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<String>,
|
||||
Vec<super::types::ComposioConnection>,
|
||||
Vec<super::types::ComposioToolSchema>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = match &kind {
|
||||
ComposioClientKind::Backend(client) => {
|
||||
let allowlist: Vec<String> = 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<String>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = 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<String, String> = 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +63,45 @@ fn de_opt_string_or_object<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Str
|
||||
|
||||
// ── Toolkits ────────────────────────────────────────────────────────
|
||||
|
||||
/// One toolkit from the live Composio catalog, forwarded verbatim from the
|
||||
/// backend (`GET /agent-integrations/composio/toolkits`).
|
||||
///
|
||||
/// The core does not interpret these fields — it passes them straight through
|
||||
/// to the desktop UI so the app no longer hardcodes toolkit display metadata
|
||||
/// (see the workspace `COMPOSIO_DYNAMIC_CATALOG_PLAN.md`). Everything except
|
||||
/// `slug` is best-effort; backends predating the dynamic catalog omit the
|
||||
/// whole `catalog` array, in which case the UI falls back to local metadata.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ComposioToolkitCatalogEntry {
|
||||
/// Toolkit slug as Composio emits it, e.g. `"googlecalendar"`.
|
||||
pub slug: String,
|
||||
/// Human-readable name, e.g. `"Google Calendar"`.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Composio-hosted logo URL (`meta.logo`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub logo: Option<String>,
|
||||
/// Short description (`meta.description`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Composio category names (`meta.categories`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub categories: Vec<String>,
|
||||
/// Whether the user can connect/use this toolkit (passed the backend gate).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<ComposioToolkitCatalogEntry>,
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
Reference in New Issue
Block a user