From c70ef5c3efa4900a85ebeacff00500a53079f856 Mon Sep 17 00:00:00 2001 From: YOMXXX Date: Sat, 30 May 2026 23:34:17 +0800 Subject: [PATCH] fix(composio): clarify Zoho Mail readiness (#2914) (#3013) --- .../composio/ComposioConnectModal.tsx | 14 +++++ app/src/pages/Skills.tsx | 56 +++++++++++++++---- .../Skills.composio-catalog.test.tsx | 27 +++++++++ src/openhuman/composio/tools_tests.rs | 52 +++++++++++++++++ .../composio/providers/tool_scope.rs | 47 ++++++++++------ 5 files changed, 168 insertions(+), 28 deletions(-) diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx index 754a21f89..799322cc9 100644 --- a/app/src/components/composio/ComposioConnectModal.tsx +++ b/app/src/components/composio/ComposioConnectModal.tsx @@ -157,6 +157,8 @@ interface ComposioConnectModalProps { toolkit: ComposioToolkitMeta; /** Existing connection (if any) from the hook. */ connection?: ComposioConnection; + /** Connected, but not yet exposed to the agent tool surface. */ + agentUnsupported?: boolean; /** Invoked on successful connect/disconnect so the parent can refresh. */ onChanged?: () => void; onClose: () => void; @@ -168,6 +170,7 @@ const POLL_TIMEOUT_MS = 5 * 60 * 1_000; export default function ComposioConnectModal({ toolkit, connection, + agentUnsupported = false, onChanged, onClose, }: ComposioConnectModalProps) { @@ -724,6 +727,17 @@ export default function ComposioConnectModal({ )} + {agentUnsupported && ( +
+
+
+ {t('composio.previewBadge')} +
+

+ {t('composio.previewTooltip')} +

+
+ )} void; onRetryGlobal: () => void; @@ -141,15 +142,20 @@ function ComposioConnectorTile({ meta, connection, hasComposioError, + agentUnsupported, testId, onOpen, onRetryGlobal, }: ComposioConnectorTileProps) { const { t } = useT(); - const state = hasComposioError ? 'error' : deriveComposioState(connection); + const rawState = deriveComposioState(connection); + const state = hasComposioError ? 'error' : rawState; + const isPreview = !hasComposioError && agentUnsupported && rawState === 'connected'; const statusLabel = hasComposioError ? t('composio.statusUnavailable') - : composioStatusLabel(connection, t); + : isPreview + ? t('composio.previewBadge') + : composioStatusLabel(connection, t); const ctaLabel = hasComposioError ? t('common.retry') : state === 'connected' @@ -162,7 +168,7 @@ function ComposioConnectorTile({ ? t('common.retry') : t('skills.connect'); - const isConnected = state === 'connected'; + const isConnected = state === 'connected' && !isPreview; const isPending = state === 'pending'; const isExpired = state === 'expired'; const isError = state === 'error' || hasComposioError; @@ -180,17 +186,27 @@ function ComposioConnectorTile({ type="button" data-testid={testId} onClick={handleClick} - title={`${meta.name} — ${meta.description}`} + title={`${meta.name} — ${isPreview ? t('composio.previewTooltip') : meta.description}`} aria-label={`${meta.name}, ${statusLabel}. ${ctaLabel}.`} - className={`group flex h-full w-full flex-col justify-center items-center rounded-2xl border p-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${ + className={`group relative flex h-full w-full flex-col justify-center items-center rounded-2xl border p-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${ isConnected ? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50 dark:border-sage-500/30 dark:bg-sage-500/10 dark:hover:bg-sage-500/15' - : isPending - ? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15' - : isExpired || isError - ? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50 dark:border-coral-500/30 dark:bg-coral-500/10 dark:hover:bg-coral-500/15' - : 'border-stone-200 bg-white hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60' + : isPreview + ? 'border-amber-200 bg-amber-50/60 shadow-[0_0_0_1px_rgba(245,158,11,0.12)] hover:bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15' + : isPending + ? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15' + : isExpired || isError + ? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50 dark:border-coral-500/30 dark:bg-coral-500/10 dark:hover:bg-coral-500/15' + : 'border-stone-200 bg-white hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60' }`}> + {isPreview && ( + + {t('composio.previewBadge')} + + )}
{meta.icon}
@@ -202,7 +218,9 @@ function ComposioConnectorTile({ className={`line-clamp-1 text-[10px] font-medium ${ hasComposioError ? 'text-amber-700 dark:text-amber-300' - : composioStatusColor(connection) + : isPreview + ? 'text-amber-700 dark:text-amber-300' + : composioStatusColor(connection) }`}> {statusLabel} @@ -400,6 +418,12 @@ export default function Skills() { error: composioError, refresh: refreshComposio, } = useComposioIntegrations(); + const { + agentReady: agentReadyComposioToolkits, + loading: agentReadyComposioLoading, + error: agentReadyComposioError, + } = useAgentReadyComposioToolkits(); + const agentReadinessKnown = !agentReadyComposioLoading && agentReadyComposioError === null; const [channelModalDef, setChannelModalDef] = useState(null); const [composioModalToolkit, setComposioModalToolkit] = useState( @@ -1074,6 +1098,11 @@ export default function Skills() { meta={meta} connection={connection} hasComposioError={Boolean(composioError)} + agentUnsupported={ + agentReadinessKnown && + deriveComposioState(connection) === 'connected' && + !agentReadyComposioToolkits.has(meta.slug) + } testId={`skill-install-composio-${meta.slug}`} onOpen={() => setComposioModalToolkit(meta)} onRetryGlobal={() => void refreshComposio()} @@ -1133,6 +1162,9 @@ export default function Skills() { { void refreshComposio(); void dismissPendingEscalationIfResolved(`composio:${composioModalToolkit.slug}`); diff --git a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx index 59c75bb5d..ee0621df2 100644 --- a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx +++ b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx @@ -176,6 +176,33 @@ describe('Skills page — Composio catalog fallback', () => { expect(previewBadges).toHaveLength(0); }); + it('marks connected Zoho Mail as preview when the agent cannot use it yet', () => { + composioToolkits = ['zoho_mail']; + composioConnectionByToolkit = new Map([ + ['zoho_mail', { id: 'ca_zoho', toolkit: 'zoho_mail', status: 'ACTIVE' }], + ]); + agentReadyState = { agentReady: new Set(['gmail']), loading: false, error: null }; + + renderWithProviders(, { initialEntries: ['/skills'] }); + + const integrationsSection = screen + .getByRole('heading', { name: 'Composio Integrations' }) + .closest('.rounded-2xl'); + expect(integrationsSection).not.toBeNull(); + const zohoTile = within(integrationsSection as HTMLElement).getByRole('button', { + name: /Zoho Mail.*Preview/i, + }); + + expect(within(zohoTile).getByTestId('composio-preview-badge-zoho_mail')).toHaveTextContent( + 'Preview' + ); + expect(within(zohoTile).getAllByText('Preview')).toHaveLength(2); + + fireEvent.click(zohoTile); + + expect(screen.getByText(/Agent integration coming soon/i)).toBeInTheDocument(); + }); + it('shows a local-mode composio API key banner when no key is configured', async () => { sessionToken = 'header.payload.local'; composioModeStatus = { result: { mode: 'direct', api_key_set: false }, logs: [] }; diff --git a/src/openhuman/composio/tools_tests.rs b/src/openhuman/composio/tools_tests.rs index 528ffea87..4dabb40e5 100644 --- a/src/openhuman/composio/tools_tests.rs +++ b/src/openhuman/composio/tools_tests.rs @@ -629,6 +629,58 @@ fn retain_connected_tools_drops_unconnected_toolkits_case_insensitively() { assert!(!names.contains(&"NOTION_CREATE_PAGE")); } +#[test] +fn retain_connected_tools_keeps_multi_segment_connected_toolkits() { + use crate::openhuman::composio::types::{ + ComposioToolFunction, ComposioToolSchema, ComposioToolsResponse, + }; + use std::collections::HashSet; + + let mut resp = ComposioToolsResponse { + tools: vec![ + ComposioToolSchema { + kind: "function".into(), + function: ComposioToolFunction { + name: "ZOHO_MAIL_SEND_EMAIL".into(), + description: None, + parameters: None, + }, + }, + ComposioToolSchema { + kind: "function".into(), + function: ComposioToolFunction { + name: "ONE_DRIVE_GET_FILE".into(), + description: None, + parameters: None, + }, + }, + ComposioToolSchema { + kind: "function".into(), + function: ComposioToolFunction { + name: "GMAIL_SEND_EMAIL".into(), + description: None, + parameters: None, + }, + }, + ], + }; + + let connected: HashSet = ["zoho_mail".to_string(), "one_drive".to_string()] + .into_iter() + .collect(); + let dropped = retain_connected_tools(&mut resp, &connected); + + assert_eq!(dropped, 1, "should only drop the disconnected gmail tool"); + let names: Vec<&str> = resp + .tools + .iter() + .map(|t| t.function.name.as_str()) + .collect(); + assert!(names.contains(&"ZOHO_MAIL_SEND_EMAIL")); + assert!(names.contains(&"ONE_DRIVE_GET_FILE")); + assert!(!names.contains(&"GMAIL_SEND_EMAIL")); +} + #[test] fn normalized_scope_toolkits_prefers_requested_filter() { use std::collections::HashSet; diff --git a/src/openhuman/memory_sync/composio/providers/tool_scope.rs b/src/openhuman/memory_sync/composio/providers/tool_scope.rs index 558641fdc..9923e370d 100644 --- a/src/openhuman/memory_sync/composio/providers/tool_scope.rs +++ b/src/openhuman/memory_sync/composio/providers/tool_scope.rs @@ -85,27 +85,26 @@ pub fn find_curated<'a>(catalog: &'a [CuratedTool], slug: &str) -> Option<&'a Cu /// Extract the toolkit slug from a Composio action slug. /// -/// All Composio action slugs follow the convention `__…` -/// (e.g. `GMAIL_SEND_EMAIL` → `gmail`). Returns the lowercased prefix -/// before the first underscore, or `None` if the slug has no underscore. -/// -/// **Assumption:** toolkit identifiers themselves do not contain -/// underscores. Composio honours this for every action we curate today -/// (`gmail`, `notion`, `googlecalendar`, …). The one historical -/// exception — `MICROSOFT_TEAMS_*` — extracts to `"microsoft"`, and -/// [`super::catalog_for_toolkit`] handles the alias by mapping both -/// `"microsoft"` and `"microsoft_teams"` to the same catalog. -/// -/// If a future toolkit ships with a multi-word slug containing an -/// underscore in the *toolkit* portion (e.g. a hypothetical -/// `FOO_BAR_LIST_ITEMS` whose toolkit is `foo_bar`), this naive split -/// must be revised — either by consulting a known-toolkits map or by -/// taking the longest-matching prefix from the registered catalogs. +/// Most Composio action slugs follow `__…` +/// (e.g. `GMAIL_SEND_EMAIL` → `gmail`). A few toolkit identifiers contain +/// underscores themselves; those need known-prefix handling so connected +/// toolkit checks do not drop actions such as `ZOHO_MAIL_*`. pub fn toolkit_from_slug(slug: &str) -> Option { let trimmed = slug.trim(); if trimmed.is_empty() { return None; } + const MULTI_SEGMENT_TOOLKIT_PREFIXES: &[(&str, &str)] = &[ + ("MICROSOFT_TEAMS_", "microsoft_teams"), + ("ONE_DRIVE_", "one_drive"), + ("ZOHO_MAIL_", "zoho_mail"), + ]; + let upper = trimmed.to_ascii_uppercase(); + for (prefix, toolkit) in MULTI_SEGMENT_TOOLKIT_PREFIXES { + if upper.starts_with(prefix) { + return Some((*toolkit).to_string()); + } + } let prefix = trimmed.split('_').next()?; if prefix.is_empty() { None @@ -163,6 +162,22 @@ mod tests { ); } + #[test] + fn toolkit_from_slug_handles_known_multi_segment_toolkits() { + assert_eq!( + toolkit_from_slug("ZOHO_MAIL_SEND_EMAIL"), + Some("zoho_mail".to_string()) + ); + assert_eq!( + toolkit_from_slug("ONE_DRIVE_GET_FILE"), + Some("one_drive".to_string()) + ); + assert_eq!( + toolkit_from_slug("MICROSOFT_TEAMS_SEND_MESSAGE"), + Some("microsoft_teams".to_string()) + ); + } + #[test] fn find_curated_is_case_insensitive() { let catalog = &[CuratedTool {