fix(composio): loading skeleton + bounded fetch timeout for Connections (no hardcoded flash) (#4079)

This commit is contained in:
sanil-23
2026-06-24 10:26:06 -07:00
committed by GitHub
parent 6294d87e4a
commit 48e16d1363
21 changed files with 289 additions and 22 deletions
+9 -2
View File
@@ -85,7 +85,14 @@ function isFresh(entry: CachedCatalog | null): boolean {
* - Stale-but-present + fetch fail → stale value served (graceful degrade).
* - Cold + fetch fail → error propagates to the caller.
*/
export async function getToolkitCatalog(): Promise<ComposioToolkitsResponse> {
export async function getToolkitCatalog(options?: {
/**
* Forwarded to `listToolkits` on a cold/stale fetch. The Connections-page
* hook passes `COMPOSIO_FETCH_TIMEOUT_MS` to bound its loading skeleton;
* omit it to inherit the global RPC default.
*/
timeoutMs?: number;
}): Promise<ComposioToolkitsResponse> {
const cached = readPersisted();
if (cached && isFresh(cached)) return cached.response;
@@ -93,7 +100,7 @@ export async function getToolkitCatalog(): Promise<ComposioToolkitsResponse> {
// Snapshot the generation so a mid-flight invalidation makes this fetch's
// result non-authoritative (see `generation`).
const startGeneration = generation;
const fetchPromise = listToolkits()
const fetchPromise = listToolkits(options)
.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
+68
View File
@@ -6,6 +6,8 @@ import {
enableTrigger,
listAgentReadyToolkits,
listAvailableTriggers,
listConnections,
listToolkits,
listTriggers,
syncConnection,
} from './composioApi';
@@ -192,6 +194,72 @@ describe('listAgentReadyToolkits', () => {
});
});
describe('Connections loading fetches (opt-in bounded timeout)', () => {
// The Connections page clears its loading skeleton only after BOTH of
// these settle (Promise.allSettled in useComposioIntegrations), so it opts
// both into the shorter 8s budget to bound the skeleton window on a cold
// cache against a down backend (#3933). The catalog is safe to time out
// early — it has a 24h stale cache plus a hardcoded fallback.
//
// The timeout is *opt-in*, not the wrapper default: `listConnections` is
// shared by the repo/issue pickers, add-memory-source dialog and connect
// modal poll, where a slow-but-successful 830s call must still complete
// (#4079 review). Default callers therefore pass no `timeoutMs`.
const EXPECTED_FETCH_TIMEOUT_MS = 8_000;
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('listToolkits omits timeoutMs by default and unwraps the envelope', async () => {
mockCallCoreRpc.mockResolvedValue({
result: { toolkits: ['gmail', 'slack'] },
logs: ['composio: 2 toolkit(s) listed'],
});
const out = await listToolkits();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_toolkits' });
expect(mockCallCoreRpc.mock.calls[0][0]).not.toHaveProperty('timeoutMs');
expect(out.toolkits).toEqual(['gmail', 'slack']);
});
it('listToolkits forwards an explicit timeoutMs option', async () => {
mockCallCoreRpc.mockResolvedValue({ result: { toolkits: [] }, logs: [] });
await listToolkits({ timeoutMs: EXPECTED_FETCH_TIMEOUT_MS });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.composio_list_toolkits',
timeoutMs: EXPECTED_FETCH_TIMEOUT_MS,
});
});
it('listConnections omits timeoutMs by default and unwraps the envelope', async () => {
mockCallCoreRpc.mockResolvedValue({
result: { connections: [{ toolkit: 'gmail', status: 'ACTIVE' }] },
logs: [],
});
const out = await listConnections();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_connections' });
expect(mockCallCoreRpc.mock.calls[0][0]).not.toHaveProperty('timeoutMs');
expect(out.connections).toHaveLength(1);
});
it('listConnections forwards an explicit timeoutMs option', async () => {
mockCallCoreRpc.mockResolvedValue({ result: { connections: [] }, logs: [] });
await listConnections({ timeoutMs: EXPECTED_FETCH_TIMEOUT_MS });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.composio_list_connections',
timeoutMs: EXPECTED_FETCH_TIMEOUT_MS,
});
});
});
describe('deleteConnection', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
+54 -4
View File
@@ -49,10 +49,51 @@ function unwrapCliEnvelope<T>(value: unknown): T {
return value as T;
}
/**
* Shorter-than-default per-call timeout the Connections page's loading
* state opts into for the two RPCs it waits on (`listToolkits` +
* `listConnections`).
*
* Both fetches are *non-critical on that surface*: the toolkit catalog has
* a 24h stale cache AND a hardcoded `KNOWN_COMPOSIO_TOOLKITS` fallback, so a
* slow or dead backend should degrade to the fallback fast rather than
* pinning the Connections grid on a loading skeleton. The skeleton window is
* bounded by the *slower* of these two calls (the hook clears `loading` only
* after `Promise.allSettled([getToolkitCatalog(), listConnections()])`
* settles — see `useComposioIntegrations` in ./hooks.ts), so BOTH must opt
* into the shorter budget. Without this the window stretches to the global
* `CORE_RPC_TIMEOUT_MS` (30s) on a cold cache against a down backend.
*
* It is deliberately **opt-in** via the `timeoutMs` option rather than the
* wrapper default: `listConnections` is also called by the GitHub repo
* picker, SmartIssuePicker, the add-memory-source dialog and the connect
* modal's poll loop, where a slow-but-successful 830s call must still be
* allowed to complete rather than being failed early (#4079 review).
*/
export const COMPOSIO_FETCH_TIMEOUT_MS = 8_000;
/** Per-call options shared by the bounded read wrappers. */
interface ComposioReadOptions {
/**
* Override the RPC timeout (ms). Omit to inherit the global
* `CORE_RPC_TIMEOUT_MS`. Pass `COMPOSIO_FETCH_TIMEOUT_MS` only from the
* Connections-page loading path (see the constant's doc comment).
*/
timeoutMs?: number;
}
// ── Read operations ───────────────────────────────────────────────
export async function listToolkits(): Promise<ComposioToolkitsResponse> {
const raw = await callCoreRpc<unknown>({ method: 'openhuman.composio_list_toolkits' });
export async function listToolkits(
options?: ComposioReadOptions
): Promise<ComposioToolkitsResponse> {
const raw = await callCoreRpc<unknown>({
method: 'openhuman.composio_list_toolkits',
// Timeout is opt-in: the Connections loading skeleton passes the shorter
// budget so the hardcoded fallback surfaces fast (#3933); other callers
// inherit the global default.
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
});
return unwrapCliEnvelope<ComposioToolkitsResponse>(raw);
}
@@ -74,8 +115,17 @@ export async function listAgentReadyToolkits(): Promise<ComposioAgentReadyToolki
return unwrapCliEnvelope<ComposioAgentReadyToolkitsResponse>(raw);
}
export async function listConnections(): Promise<ComposioConnectionsResponse> {
const raw = await callCoreRpc<unknown>({ method: 'openhuman.composio_list_connections' });
export async function listConnections(
options?: ComposioReadOptions
): Promise<ComposioConnectionsResponse> {
const raw = await callCoreRpc<unknown>({
method: 'openhuman.composio_list_connections',
// Timeout is opt-in (see `listToolkits`): only the Connections loading
// path passes the shorter budget. Shared callers (repo/issue pickers,
// add-memory-source, connect-modal poll) inherit the global default so a
// slow-but-successful call still completes (#4079 review).
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
});
return unwrapCliEnvelope<ComposioConnectionsResponse>(raw);
}
+3 -2
View File
@@ -8,8 +8,9 @@ const mockOpenhumanComposioGetMode = vi.fn();
let sessionToken = 'jwt-abc';
vi.mock('./composioApi', () => ({
listToolkits: () => mockListToolkits(),
listConnections: () => mockListConnections(),
COMPOSIO_FETCH_TIMEOUT_MS: 8_000,
listToolkits: (options?: { timeoutMs?: number }) => mockListToolkits(options),
listConnections: (options?: { timeoutMs?: number }) => mockListConnections(options),
listAgentReadyToolkits: () => mockListAgentReadyToolkits(),
}));
+7 -4
View File
@@ -4,7 +4,7 @@ import { isLocalSessionToken } from '../../utils/localSession';
import { openhumanComposioGetMode } from '../../utils/tauriCommands';
import { getCoreStateSnapshot } from '../coreState/store';
import { getToolkitCatalog, invalidateToolkitCatalogCache } from './catalogCache';
import { listAgentReadyToolkits, listConnections } from './composioApi';
import { COMPOSIO_FETCH_TIMEOUT_MS, listAgentReadyToolkits, listConnections } from './composioApi';
import { canonicalizeComposioToolkitSlug } from './toolkitSlug';
import type { ComposioConnection, ComposioToolkitCatalogEntry } from './types';
@@ -99,9 +99,12 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
let nextError: string | null = null;
try {
// Bound both fetches so the loading skeleton can't pin past ~8s on a
// cold cache / down backend. This is the only path that opts into the
// shorter budget — see COMPOSIO_FETCH_TIMEOUT_MS.
const [toolkitsResult, connectionsResult] = await Promise.allSettled([
getToolkitCatalog(),
listConnections(),
getToolkitCatalog({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }),
listConnections({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }),
]);
if (!mountedRef.current) return;
@@ -139,7 +142,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
void refresh();
if (pollIntervalMs <= 0 || fetchEnabled !== true) return;
const id = window.setInterval(() => {
void listConnections()
void listConnections({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS })
.then(resp => {
if (!mountedRef.current) return;
setConnections(resp.connections ?? []);
+1
View File
@@ -479,6 +479,7 @@ const messages: TranslationMap = {
'skills.title': 'الاتصالات',
'skills.search': 'البحث في الاتصالات...',
'skills.noResults': 'لم يتم العثور على اتصالات',
'skills.loadingIntegrations': 'جارٍ تحميل التكاملات…',
'skills.connect': 'توصيل',
'skills.disconnect': 'قطع الاتصال',
'skills.configure': 'إدارة',
+1
View File
@@ -487,6 +487,7 @@ const messages: TranslationMap = {
'skills.title': 'সংযোগ',
'skills.search': 'সংযোগ খুঁজুন...',
'skills.noResults': 'কোনো সংযোগ পাওয়া যায়নি',
'skills.loadingIntegrations': 'ইন্টিগ্রেশন লোড হচ্ছে…',
'skills.connect': 'সংযুক্ত করুন',
'skills.disconnect': 'সংযোগ বিচ্ছিন্ন',
'skills.configure': 'পরিচালনা',
+1
View File
@@ -502,6 +502,7 @@ const messages: TranslationMap = {
'skills.title': 'Verbindungen',
'skills.search': 'Verbindungen suchen...',
'skills.noResults': 'Keine Verbindungen gefunden',
'skills.loadingIntegrations': 'Integrationen werden geladen…',
'skills.connect': 'Verbinden',
'skills.disconnect': 'Trennen',
'skills.configure': 'Verwalten',
+1
View File
@@ -545,6 +545,7 @@ const en: TranslationMap = {
'skills.title': 'Connections',
'skills.search': 'Search connections...',
'skills.noResults': 'No connections found',
'skills.loadingIntegrations': 'Loading integrations…',
'skills.connect': 'Connect',
'skills.disconnect': 'Disconnect',
'skills.configure': 'Manage',
+1
View File
@@ -500,6 +500,7 @@ const messages: TranslationMap = {
'skills.title': 'Conexiones',
'skills.search': 'Buscar conexiones...',
'skills.noResults': 'No se encontraron conexiones',
'skills.loadingIntegrations': 'Cargando integraciones…',
'skills.connect': 'Conectar',
'skills.disconnect': 'Desconectar',
'skills.configure': 'Administrar',
+1
View File
@@ -501,6 +501,7 @@ const messages: TranslationMap = {
'skills.title': 'Connexions',
'skills.search': 'Rechercher des connexions…',
'skills.noResults': 'Aucune connexion trouvée',
'skills.loadingIntegrations': 'Chargement des intégrations…',
'skills.connect': 'Connecter',
'skills.disconnect': 'Déconnecter',
'skills.configure': 'Gérer',
+1
View File
@@ -486,6 +486,7 @@ const messages: TranslationMap = {
'skills.title': 'कनेक्शन',
'skills.search': 'कनेक्शन सर्च करें...',
'skills.noResults': 'कोई कनेक्शन नहीं मिला',
'skills.loadingIntegrations': 'इंटीग्रेशन लोड हो रहे हैं…',
'skills.connect': 'कनेक्ट करें',
'skills.disconnect': 'डिसकनेक्ट करें',
'skills.configure': 'मैनेज करें',
+1
View File
@@ -489,6 +489,7 @@ const messages: TranslationMap = {
'skills.title': 'Koneksi',
'skills.search': 'Cari koneksi...',
'skills.noResults': 'Koneksi tidak ditemukan',
'skills.loadingIntegrations': 'Memuat integrasi…',
'skills.connect': 'Hubungkan',
'skills.disconnect': 'Putuskan',
'skills.configure': 'Kelola',
+1
View File
@@ -497,6 +497,7 @@ const messages: TranslationMap = {
'skills.title': 'Connessioni',
'skills.search': 'Cerca connessioni...',
'skills.noResults': 'Nessuna connessione trovata',
'skills.loadingIntegrations': 'Caricamento delle integrazioni…',
'skills.connect': 'Connetti',
'skills.disconnect': 'Disconnetti',
'skills.configure': 'Gestisci',
+1
View File
@@ -486,6 +486,7 @@ const messages: TranslationMap = {
'skills.title': '연결',
'skills.search': '연결 검색...',
'skills.noResults': '연결을 찾을 수 없습니다',
'skills.loadingIntegrations': '통합을 불러오는 중…',
'skills.connect': '연결',
'skills.disconnect': '연결 해제',
'skills.configure': '관리',
+1
View File
@@ -492,6 +492,7 @@ const messages: TranslationMap = {
'skills.title': 'Połączenia',
'skills.search': 'Szukaj połączeń...',
'skills.noResults': 'Nie znaleziono połączeń',
'skills.loadingIntegrations': 'Ładowanie integracji…',
'skills.connect': 'Połącz',
'skills.disconnect': 'Rozłącz',
'skills.configure': 'Zarządzaj',
+1
View File
@@ -500,6 +500,7 @@ const messages: TranslationMap = {
'skills.title': 'Conexões',
'skills.search': 'Pesquisar conexões...',
'skills.noResults': 'Nenhuma conexão encontrada',
'skills.loadingIntegrations': 'Carregando integrações…',
'skills.connect': 'Conectar',
'skills.disconnect': 'Desconectar',
'skills.configure': 'Gerenciar',
+1
View File
@@ -493,6 +493,7 @@ const messages: TranslationMap = {
'skills.title': 'Подключения',
'skills.search': 'Поиск подключений...',
'skills.noResults': 'Подключения не найдены',
'skills.loadingIntegrations': 'Загрузка интеграций…',
'skills.connect': 'Подключить',
'skills.disconnect': 'Отключить',
'skills.configure': 'Управление',
+1
View File
@@ -469,6 +469,7 @@ const messages: TranslationMap = {
'skills.title': '连接',
'skills.search': '搜索连接...',
'skills.noResults': '未找到连接',
'skills.loadingIntegrations': '正在加载集成…',
'skills.connect': '连接',
'skills.disconnect': '断开',
'skills.configure': '配置',
+59 -9
View File
@@ -467,6 +467,7 @@ export default function Skills() {
catalogByToolkit: composioCatalogByToolkit = new Map(),
connectionByToolkit: composioConnectionByToolkit,
connectionsByToolkit: composioConnectionsByToolkit,
loading: composioLoading,
error: composioError,
refresh: refreshComposio,
} = useComposioIntegrations();
@@ -541,25 +542,42 @@ export default function Skills() {
const composioCatalogToolkits = useMemo(() => {
const normalizedToolkits = composioToolkits.map(slug => canonicalizeComposioToolkitSlug(slug));
// 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.
// Base-list selection (see COMPOSIO_DYNAMIC_CATALOG_PLAN.md / #3933):
// 1. Dynamic catalog present → drive the grid straight off the backend.
// 2. Still fetching (no catalog yet) → render NOTHING from the hardcoded
// list. The grid shows a loading skeleton instead. This is the fix for
// the "flash of stale hardcoded toolkits" that appeared before the
// backend catalog landed.
// 3. Fetch finished with no catalog (a genuine failure, or an older core
// that predates the dynamic catalog) → fall back to the hardcoded
// KNOWN_COMPOSIO_TOOLKITS so the grid is never empty.
const dynamicSlugs = Array.from(composioCatalogByToolkit.keys());
const hasDynamicCatalog = dynamicSlugs.length > 0;
const baseSlugs = hasDynamicCatalog ? dynamicSlugs : KNOWN_COMPOSIO_TOOLKITS;
let baseSlugs: readonly string[];
let source: 'dynamic-backend' | 'loading' | 'hardcoded-fallback';
if (hasDynamicCatalog) {
baseSlugs = dynamicSlugs;
source = 'dynamic-backend';
} else if (composioLoading) {
baseSlugs = [];
source = 'loading';
} else {
baseSlugs = KNOWN_COMPOSIO_TOOLKITS;
source = 'hardcoded-fallback';
}
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',
source,
dynamicCount: dynamicSlugs.length,
toolkitCount: composioToolkits.length,
connectionCount: composioConnectionByToolkit.size,
loading: composioLoading,
hasError: Boolean(composioError),
missingKnownToolkits: hasDynamicCatalog ? [] : missingKnownToolkits,
missingKnownToolkits: source === 'hardcoded-fallback' ? missingKnownToolkits : [],
});
}
@@ -568,7 +586,13 @@ export default function Skills() {
return Array.from(new Set([...baseSlugs, ...normalizedToolkits])).sort((a, b) =>
a.localeCompare(b)
);
}, [composioToolkits, composioCatalogByToolkit, composioConnectionByToolkit, composioError]);
}, [
composioToolkits,
composioCatalogByToolkit,
composioConnectionByToolkit,
composioLoading,
composioError,
]);
// Unified item list
const allItems: SkillItem[] = useMemo(() => {
@@ -1077,7 +1101,33 @@ export default function Skills() {
</div>
)}
{!showLocalComposioApiKeyBanner &&
(composioSortedEntries.length > 0 ? (
// While the dynamic catalog is still being fetched and we
// have nothing real to show yet, render a loading skeleton
// instead of the hardcoded toolkit list. The hardcoded
// KNOWN_COMPOSIO_TOOLKITS list is only used as a post-fetch
// fallback (see composioCatalogToolkits), never during the
// in-flight loading window (#3933).
(composioLoading && composioSortedEntries.length === 0 ? (
<div
className="grid gap-2 sm:gap-3"
data-testid="composio-integrations-loading"
role="status"
aria-label={t('skills.loadingIntegrations')}
aria-busy="true"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))',
gridAutoRows: '6.5rem',
}}>
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
data-testid="composio-skeleton-tile"
aria-hidden="true"
className="animate-pulse rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-100 dark:bg-neutral-800/60"
/>
))}
</div>
) : composioSortedEntries.length > 0 ? (
<div
className="grid gap-2 sm:gap-3"
style={{
@@ -7,6 +7,7 @@ import Skills from '../Skills';
let composioRefresh = vi.fn();
let composioError: string | null = null;
let composioLoading = false;
let composioToolkits: string[] = [];
let composioCatalogByToolkit = new Map();
let composioConnectionByToolkit = new Map();
@@ -44,7 +45,7 @@ vi.mock('../../lib/composio/hooks', () => ({
composioConnectionsByToolkitOverride ??
new Map(Array.from(composioConnectionByToolkit.entries()).map(([k, v]) => [k, [v]])),
refresh: composioRefresh,
loading: false,
loading: composioLoading,
error: composioError,
}),
// Issue #2283 / CodeRabbit on #2361: Skills.tsx consumes
@@ -72,6 +73,7 @@ describe('Skills page — Composio catalog fallback', () => {
beforeEach(() => {
composioRefresh = vi.fn();
composioError = null;
composioLoading = false;
composioToolkits = [];
composioCatalogByToolkit = new Map();
composioConnectionByToolkit = new Map();
@@ -142,6 +144,78 @@ describe('Skills page — Composio catalog fallback', () => {
).not.toBeInTheDocument();
});
it('shows a loading skeleton (not the hardcoded list) while the catalog is still being fetched', () => {
// #3933: during the in-flight fetch we must NOT flash the hardcoded
// KNOWN_COMPOSIO_TOOLKITS list. Until the backend catalog lands the grid
// renders a loading skeleton instead.
composioLoading = true;
composioCatalogByToolkit = new Map();
composioToolkits = [];
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
const integrationsSection = screen.getByTestId('composio-integrations-card');
// Loading skeleton is shown…
expect(
within(integrationsSection as HTMLElement).getByTestId('composio-integrations-loading')
).toBeInTheDocument();
expect(
within(integrationsSection as HTMLElement).queryAllByTestId('composio-skeleton-tile').length
).toBeGreaterThan(0);
// …and none of the hardcoded fallback toolkits are rendered yet.
expect(
within(integrationsSection as HTMLElement).queryByText('Discord')
).not.toBeInTheDocument();
expect(within(integrationsSection as HTMLElement).queryByText('Gmail')).not.toBeInTheDocument();
expect(within(integrationsSection as HTMLElement).queryByText('Slack')).not.toBeInTheDocument();
});
it('falls back to the hardcoded list once the fetch finishes without a dynamic catalog', () => {
// The hardcoded list is a *post-fetch* fallback: when loading completes and
// the backend supplied no catalog (a failure or an older core), the grid
// must still render so it is never empty.
composioLoading = false;
composioCatalogByToolkit = new Map();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
const integrationsSection = screen.getByTestId('composio-integrations-card');
expect(
within(integrationsSection as HTMLElement).queryByTestId('composio-integrations-loading')
).not.toBeInTheDocument();
expect(within(integrationsSection as HTMLElement).getByText('Discord')).toBeInTheDocument();
expect(within(integrationsSection as HTMLElement).getByText('Gmail')).toBeInTheDocument();
});
it('shows the dynamic catalog (not the skeleton) even if a fetch is still in flight once entries exist', () => {
// Defensive: if catalog entries are already present we render them rather
// than the skeleton, even should `loading` still be true for a poll cycle.
composioLoading = true;
composioCatalogByToolkit = new Map([
[
'gmail',
{ slug: 'gmail', name: 'Gmail Dynamic', categories: ['productivity'], enabled: true },
],
]);
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
const integrationsSection = screen.getByTestId('composio-integrations-card');
expect(
within(integrationsSection as HTMLElement).queryByTestId('composio-integrations-loading')
).not.toBeInTheDocument();
expect(
within(integrationsSection as HTMLElement).getByText('Gmail Dynamic')
).toBeInTheDocument();
// Hardcoded-only toolkit absent from the dynamic catalog stays hidden.
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';