fix(composio): clarify Zoho Mail readiness (#2914) (#3013)

This commit is contained in:
YOMXXX
2026-05-30 08:34:17 -07:00
committed by GitHub
parent 6605dfc966
commit c70ef5c3ef
5 changed files with 168 additions and 28 deletions
@@ -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({
)}
</div>
</div>
{agentUnsupported && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 dark:border-amber-500/30 dark:bg-amber-500/10">
<div className="flex items-center gap-2 text-sm font-medium text-amber-800 dark:text-amber-200">
<div className="h-2 w-2 rounded-full bg-amber-500" />
{t('composio.previewBadge')}
</div>
<p className="mt-2 text-xs leading-relaxed text-amber-700 dark:text-amber-200/80">
{t('composio.previewTooltip')}
</p>
</div>
)}
<ScopeToggles
scopes={scopes}
savingScope={savingScope}
+44 -12
View File
@@ -33,7 +33,7 @@ import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocomp
import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligence/useScreenIntelligenceSkillStatus';
import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { useComposioIntegrations } from '../lib/composio/hooks';
import { useAgentReadyComposioToolkits, useComposioIntegrations } from '../lib/composio/hooks';
import { canonicalizeComposioToolkitSlug } from '../lib/composio/toolkitSlug';
import { type ComposioConnection, deriveComposioState } from '../lib/composio/types';
import { getCoreStateSnapshot } from '../lib/coreState/store';
@@ -132,6 +132,7 @@ interface ComposioConnectorTileProps {
meta: ComposioToolkitMeta;
connection: ComposioConnection | undefined;
hasComposioError: boolean;
agentUnsupported: boolean;
testId?: string;
onOpen: () => 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 && (
<span
data-testid={`composio-preview-badge-${meta.slug}`}
className="absolute right-1.5 top-1.5 max-w-[4.5rem] truncate rounded-full border border-amber-200 bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200"
title={t('composio.previewTooltip')}>
{t('composio.previewBadge')}
</span>
)}
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center text-stone-700 dark:text-neutral-200 [&_img]:max-h-10 [&_img]:max-w-10 [&_svg]:h-8 [&_svg]:w-8">
{meta.icon}
</div>
@@ -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}
</span>
@@ -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<ChannelDefinition | null>(null);
const [composioModalToolkit, setComposioModalToolkit] = useState<ComposioToolkitMeta | null>(
@@ -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() {
<ComposioConnectModal
toolkit={composioModalToolkit}
connection={composioConnectionByToolkit.get(composioModalToolkit.slug)}
agentUnsupported={
agentReadinessKnown && !agentReadyComposioToolkits.has(composioModalToolkit.slug)
}
onChanged={() => {
void refreshComposio();
void dismissPendingEscalationIfResolved(`composio:${composioModalToolkit.slug}`);
@@ -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<string>(['gmail']), loading: false, error: null };
renderWithProviders(<Skills />, { 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: [] };
+52
View File
@@ -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<String> = ["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;
@@ -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 `<TOOLKIT>_<VERB>_…`
/// (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 `<TOOLKIT>_<VERB>_…`
/// (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<String> {
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 {