mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -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
@@ -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: [] };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user