mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
feat(chat): install skills and connect integrations inline (#4062)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
48e16d1363
commit
2b90955482
@@ -0,0 +1,378 @@
|
||||
import debug from 'debug';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { authorize, listConnections } from '../../lib/composio/composioApi';
|
||||
import { canonicalizeComposioToolkitSlug } from '../../lib/composio/toolkitSlug';
|
||||
import { deriveComposioState } from '../../lib/composio/types';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import { clearPendingApprovalForThread, type PendingApproval } from '../../store/chatRuntimeSlice';
|
||||
import { useAppDispatch } from '../../store/hooks';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import {
|
||||
getRequiredFieldsForToolkit,
|
||||
validateRequiredFieldValues,
|
||||
} from '../composio/toolkitRequiredFields';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
/**
|
||||
* Inline OAuth connect card (#3993).
|
||||
*
|
||||
* Rendered in place of {@link ApprovalRequestCard} when the agent calls the
|
||||
* `composio_connect` tool — that tool parks on the same ApprovalGate, so the
|
||||
* request arrives over the identical `approval_request` socket path, but the
|
||||
* surface is a **Connect** button rather than approve/deny. Clicking it runs
|
||||
* `composio_authorize`, opens the OAuth handoff in the browser, and polls
|
||||
* `composio_list_connections` until the toolkit flips to ACTIVE — at which
|
||||
* point it resolves the parked tool call with `approve_once` so the agent
|
||||
* resumes in the same turn. Cancel (or the gate's 10-minute TTL) resolves it
|
||||
* as `deny`.
|
||||
*
|
||||
* Provider-specific required fields (WhatsApp `waba_id`, Jira `subdomain`,
|
||||
* Dynamics 365 `org_name`) are collected inline from the
|
||||
* [`toolkitRequiredFields`] registry before the OAuth handoff — so even
|
||||
* field-gated toolkits connect entirely in-chat rather than failing with a
|
||||
* raw `ConnectedAccount_MissingRequiredFields` (code 612) error. Mirrors the
|
||||
* polling + field-collection contract of `ComposioConnectModal` so the two
|
||||
* connect surfaces behave identically.
|
||||
*/
|
||||
const log = debug('openhuman:chat:integration-connect-card');
|
||||
|
||||
const POLL_INTERVAL_MS = 4_000;
|
||||
const POLL_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||
|
||||
/**
|
||||
* Composio error slug for missing required fields (code 612). Defensive
|
||||
* recovery path: if the backend starts requiring a field the registry hasn't
|
||||
* caught up on, surface a clear message instead of a dead retry loop.
|
||||
*/
|
||||
const MISSING_REQUIRED_FIELDS_SLUG = 'ConnectedAccount_MissingRequiredFields';
|
||||
|
||||
type Phase = 'idle' | 'connecting' | 'error';
|
||||
|
||||
interface Props {
|
||||
threadId: string;
|
||||
approval: PendingApproval;
|
||||
}
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export const IntegrationConnectCard: React.FC<Props> = ({ threadId, approval }) => {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
// Canonicalize the slug the agent supplied (e.g. `google_drive` →
|
||||
// `googledrive`) so authorize / list-connections hit the form Composio's
|
||||
// backend expects (#3993). Defensive: the core already canonicalizes too.
|
||||
const toolkit = canonicalizeComposioToolkitSlug(approval.toolkit ?? '');
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
// Cleared to false on a permanent backend rejection (no auth config, unknown
|
||||
// toolkit, 400) so the card drops its Retry affordance — retrying won't help.
|
||||
const [retryable, setRetryable] = useState(true);
|
||||
|
||||
// Provider-specific required fields are sourced from the declarative
|
||||
// registry — no per-toolkit branches here (mirrors ComposioConnectModal).
|
||||
const requiredFields = useMemo(() => getRequiredFieldsForToolkit(toolkit), [toolkit]);
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const pollDeadlineRef = useRef<number>(0);
|
||||
const isPollingRef = useRef<boolean>(false);
|
||||
const inFlightRef = useRef<boolean>(false);
|
||||
// Set once the card is dismissed (Deny) or unmounted, so an `authorize()`
|
||||
// call still in flight doesn't open OAuth / start polling afterwards.
|
||||
const cancelledRef = useRef<boolean>(false);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
isPollingRef.current = false;
|
||||
if (pollTimerRef.current != null) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop polling if the card unmounts (turn ended, thread switched, decided),
|
||||
// and mark cancelled so any in-flight authorize continuation aborts.
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelledRef.current = true;
|
||||
stopPolling();
|
||||
},
|
||||
[stopPolling]
|
||||
);
|
||||
|
||||
// Resolve the parked `composio_connect` tool call. `approve_once` once the
|
||||
// connection is live; `deny` when the user cancels. Clears the card on a
|
||||
// successful decide — ChatRuntimeProvider also clears on turn end.
|
||||
const resolveGate = useCallback(
|
||||
async (decision: 'approve_once' | 'deny') => {
|
||||
try {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.approval_decide',
|
||||
params: { request_id: approval.requestId, decision },
|
||||
});
|
||||
} catch (e) {
|
||||
// The backend request is still parked. Clearing the card here would
|
||||
// drop the only surface that can retry/deny it, blocking the thread
|
||||
// until the gate TTL expires — so keep the card mounted and surface
|
||||
// the failure instead of clearing it (#4062, coderabbit review).
|
||||
log('approval_decide(%s) failed: %o', decision, e);
|
||||
setPhase('error');
|
||||
setErrorMsg(t('chat.approval.error'));
|
||||
return;
|
||||
}
|
||||
dispatch(clearPendingApprovalForThread({ threadId }));
|
||||
},
|
||||
[approval.requestId, dispatch, threadId, t]
|
||||
);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
stopPolling();
|
||||
isPollingRef.current = true;
|
||||
pollDeadlineRef.current = Date.now() + POLL_TIMEOUT_MS;
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (!isPollingRef.current) return;
|
||||
pollTimerRef.current = window.setTimeout(() => void tick(), POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (inFlightRef.current || !isPollingRef.current) return;
|
||||
if (Date.now() > pollDeadlineRef.current) {
|
||||
stopPolling();
|
||||
setPhase('error');
|
||||
setErrorMsg(t('composio.connect.oauthTimeout'));
|
||||
// Resolve the parked tool call now so the agent resumes immediately
|
||||
// instead of blocking until the 10-min gate TTL (#3993). The agent
|
||||
// relays the timeout and the user can ask to connect again.
|
||||
await resolveGate('deny');
|
||||
return;
|
||||
}
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const resp = await listConnections();
|
||||
// Scan ALL rows for this toolkit — list_connections returns every row
|
||||
// (failed / pending / multiple accounts), so the freshly-authorized
|
||||
// ACTIVE row can sit behind an older FAILED or pending one (#3993,
|
||||
// codex review). Approve if any row is connected.
|
||||
const matches = resp.connections.filter(
|
||||
c => c.toolkit.toLowerCase() === toolkit.toLowerCase()
|
||||
);
|
||||
if (matches.some(c => deriveComposioState(c) === 'connected')) {
|
||||
stopPolling();
|
||||
await resolveGate('approve_once');
|
||||
return;
|
||||
}
|
||||
// Keep waiting while any handoff is still in flight; only surface an
|
||||
// error once a failed row exists and nothing is pending.
|
||||
const pending = matches.some(c => deriveComposioState(c) === 'pending');
|
||||
const errored = matches.find(c => deriveComposioState(c) === 'error');
|
||||
if (errored && !pending) {
|
||||
stopPolling();
|
||||
setPhase('error');
|
||||
setErrorMsg(
|
||||
t('composio.connect.connectionFailed').replace('{status}', String(errored.status))
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Transient poll failures are expected mid-handoff — retry next tick.
|
||||
log('connection poll failed: %o', err);
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
void tick();
|
||||
}, [resolveGate, stopPolling, t, toolkit]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (phase === 'connecting' || !toolkit) return;
|
||||
// A prior Deny (or a failed decide that kept the card mounted) may have set
|
||||
// cancelledRef — clear it so this fresh, user-initiated attempt isn't
|
||||
// aborted by the post-authorize cancellation guard.
|
||||
cancelledRef.current = false;
|
||||
|
||||
// Collect + validate provider-specific required fields before the OAuth
|
||||
// handoff so field-gated toolkits don't hit a 612 error mid-flow.
|
||||
let extraParams: Record<string, string> | undefined;
|
||||
if (requiredFields.length > 0) {
|
||||
const errors = validateRequiredFieldValues(requiredFields, fieldValues);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
extraParams = {};
|
||||
for (const f of requiredFields) {
|
||||
extraParams[f.key] = (fieldValues[f.key] ?? '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
setPhase('connecting');
|
||||
setErrorMsg(null);
|
||||
setRetryable(true);
|
||||
try {
|
||||
const resp = await authorize(toolkit, extraParams);
|
||||
// The user may have hit Deny / dismissed the card while authorize was in
|
||||
// flight — abort so we don't open OAuth or start polling after the gate
|
||||
// was already denied, nor race a second approval_decide (codex review).
|
||||
if (cancelledRef.current) return;
|
||||
try {
|
||||
await openUrl(resp.connectUrl);
|
||||
} catch (openErr) {
|
||||
// Opening the browser failed, but the handoff may still be reachable;
|
||||
// keep polling and let the user reopen if needed.
|
||||
log('openUrl failed: %o', openErr);
|
||||
}
|
||||
startPolling();
|
||||
} catch (e) {
|
||||
log('authorize failed: %o', e);
|
||||
setPhase('error');
|
||||
// Defensive: backend reports a required field the registry lacks. Without
|
||||
// a field definition we can't collect it inline, so surface a clear
|
||||
// "needs extra setup" message rather than a retry that will fail again.
|
||||
if (errorText(e).includes(MISSING_REQUIRED_FIELDS_SLUG) && requiredFields.length === 0) {
|
||||
setErrorMsg(t('composio.connect.additionalConfigRequired'));
|
||||
} else {
|
||||
// Surface the backend's actual reason (e.g. "toolkit not in allowlist")
|
||||
// so a failed connect is diagnosable — a bare "Connection failed." hides
|
||||
// whether the toolkit is unsupported, mis-slugged, or a transient error.
|
||||
// Composio authorize errors are connection diagnostics (no PII); bound
|
||||
// the length and collapse whitespace before showing.
|
||||
// Strip the "(status: {status})" clause locale-robustly — the English
|
||||
// literal differs per locale (de "Status", fr "statut", …), so match the
|
||||
// parenthetical containing the {status} token rather than the English
|
||||
// wording, else non-English users see a literal "{status}" (#3993).
|
||||
const base = t('composio.connect.connectionFailed')
|
||||
.replace(/\s*\([^)]*\{status\}[^)]*\)/, '')
|
||||
.trim();
|
||||
const reason = errorText(e).replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
setErrorMsg(reason ? `${base} ${reason}` : base);
|
||||
// Permanent backend rejections won't change on retry — drop the Retry
|
||||
// affordance so the user isn't looped on a doomed connect (#3993).
|
||||
if (/no auth config|not a valid toolkit|unknown toolkit|not found|\b400\b/i.test(reason)) {
|
||||
setRetryable(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [phase, requiredFields, fieldValues, startPolling, t, toolkit]);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
cancelledRef.current = true;
|
||||
stopPolling();
|
||||
await resolveGate('deny');
|
||||
}, [resolveGate, stopPolling]);
|
||||
|
||||
const connecting = phase === 'connecting';
|
||||
const showFields = requiredFields.length > 0 && !connecting;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={approval.message || t('composio.connect.connect')}
|
||||
className="rounded-xl border border-primary-200 bg-primary-50 p-3.5 text-sm shadow-sm dark:border-primary-800 dark:bg-primary-950">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary-100 text-sm text-primary-600 dark:bg-primary-900 dark:text-primary-300">
|
||||
🔗
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
{approval.message || t('chat.approval.fallback')}
|
||||
</p>
|
||||
|
||||
{showFields && (
|
||||
<div className="mt-2.5 flex flex-col gap-2.5">
|
||||
{requiredFields.map(field => (
|
||||
<label
|
||||
key={field.key}
|
||||
className="block text-xs text-neutral-700 dark:text-neutral-300">
|
||||
<span className="font-medium">{t(field.labelKey)}</span>
|
||||
<span className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={fieldValues[field.key] ?? ''}
|
||||
placeholder={field.placeholderKey ? t(field.placeholderKey) : undefined}
|
||||
onChange={e =>
|
||||
setFieldValues(prev => ({ ...prev, [field.key]: e.target.value }))
|
||||
}
|
||||
className="min-w-0 flex-1 rounded-lg border border-neutral-300 bg-neutral-0 px-2.5 py-1.5 text-ink outline-none transition focus:border-primary-500 focus:ring-1 focus:ring-primary-500 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
{field.suffix && (
|
||||
<span className="shrink-0 text-neutral-400 dark:text-neutral-500">
|
||||
{field.suffix}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{field.hintKey && (
|
||||
<span className="mt-1 block text-neutral-500 dark:text-neutral-400">
|
||||
{t(field.hintKey)}
|
||||
</span>
|
||||
)}
|
||||
{fieldErrors[field.key] && (
|
||||
<span className="mt-1 block text-coral-600 dark:text-coral-400">
|
||||
{t(fieldErrors[field.key])}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connecting && (
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-primary-700 dark:text-primary-300">
|
||||
<span aria-hidden className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary-500" />
|
||||
{t('composio.connect.waitingHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{t('chat.approval.tool')}{' '}
|
||||
<span className="font-mono text-neutral-500 dark:text-neutral-400">
|
||||
{approval.toolName}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{errorMsg && (
|
||||
<p className="mt-2 text-xs text-coral-600 dark:text-coral-400">⚠ {errorMsg}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{/* Hide Connect/Retry on a permanent rejection — retrying a toolkit
|
||||
the backend can't authorize just loops. Dismiss stays. */}
|
||||
{!(phase === 'error' && !retryable) && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-analytics-id="chat-integration-connect"
|
||||
onClick={() => void connect()}
|
||||
disabled={connecting || !toolkit}>
|
||||
{connecting
|
||||
? t('chat.approval.deciding')
|
||||
: phase === 'error'
|
||||
? t('composio.connect.retryConnection')
|
||||
: t('composio.connect.connect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-analytics-id="chat-integration-connect-cancel"
|
||||
onClick={() => void cancel()}>
|
||||
{t('chat.approval.deny')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationConnectCard;
|
||||
@@ -0,0 +1,340 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { authorize, listConnections } from '../../../lib/composio/composioApi';
|
||||
import { deriveComposioState } from '../../../lib/composio/types';
|
||||
import { callCoreRpc } from '../../../services/coreRpcClient';
|
||||
import chatRuntimeReducer, {
|
||||
type PendingApproval,
|
||||
setPendingApprovalForThread,
|
||||
} from '../../../store/chatRuntimeSlice';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import IntegrationConnectCard from '../IntegrationConnectCard';
|
||||
|
||||
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
vi.mock('../../../lib/composio/composioApi', () => ({
|
||||
authorize: vi.fn(),
|
||||
listConnections: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../lib/composio/types', () => ({ deriveComposioState: vi.fn() }));
|
||||
|
||||
const THREAD = 't1';
|
||||
const approval: PendingApproval = {
|
||||
requestId: 'req-connect-1',
|
||||
toolName: 'composio_connect',
|
||||
message: 'Connect gmail to complete your task',
|
||||
toolkit: 'gmail',
|
||||
};
|
||||
|
||||
function renderCard() {
|
||||
const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
|
||||
store.dispatch(setPendingApprovalForThread({ threadId: THREAD, approval }));
|
||||
const utils = render(
|
||||
<Provider store={store}>
|
||||
<IntegrationConnectCard threadId={THREAD} approval={approval} />
|
||||
</Provider>
|
||||
);
|
||||
return { store, ...utils };
|
||||
}
|
||||
|
||||
describe('IntegrationConnectCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the connect prompt, Connect button, and tool name', () => {
|
||||
renderCard();
|
||||
// The action message leads the card (no alarming "Approval needed" title).
|
||||
expect(screen.getByText('Connect gmail to complete your task')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connect')).toBeInTheDocument();
|
||||
expect(screen.getByText('composio_connect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Connect authorizes, opens the OAuth url, polls, and resolves approve_once on active', async () => {
|
||||
vi.mocked(authorize).mockResolvedValueOnce({
|
||||
connectUrl: 'https://hosted.composio.dev/abc',
|
||||
connectionId: 'conn-1',
|
||||
} as Awaited<ReturnType<typeof authorize>>);
|
||||
vi.mocked(listConnections).mockResolvedValue({
|
||||
connections: [{ toolkit: 'gmail', status: 'ACTIVE' }],
|
||||
} as Awaited<ReturnType<typeof listConnections>>);
|
||||
// First poll tick sees the toolkit ACTIVE.
|
||||
vi.mocked(deriveComposioState).mockReturnValue('connected');
|
||||
vi.mocked(callCoreRpc).mockResolvedValue({});
|
||||
|
||||
const { store } = renderCard();
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
// No required fields for gmail → authorize called with no extra params.
|
||||
await waitFor(() => expect(authorize).toHaveBeenCalledWith('gmail', undefined));
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith('https://hosted.composio.dev/abc'));
|
||||
// Polling detected the live connection → parked tool call resolved as approved.
|
||||
await waitFor(() =>
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.approval_decide',
|
||||
params: { request_id: 'req-connect-1', decision: 'approve_once' },
|
||||
})
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(store.getState().chatRuntime.pendingApprovalByThread[THREAD]).toBeUndefined()
|
||||
);
|
||||
});
|
||||
|
||||
it('Cancel resolves the parked call as deny', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({});
|
||||
const { store } = renderCard();
|
||||
|
||||
fireEvent.click(screen.getByText('Deny'));
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.approval_decide',
|
||||
params: { request_id: 'req-connect-1', decision: 'deny' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(store.getState().chatRuntime.pendingApprovalByThread[THREAD]).toBeUndefined()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the card mounted and surfaces an error when approval_decide fails', async () => {
|
||||
// The decide RPC throws — the backend request is still parked, so clearing
|
||||
// the card would strand the thread until the gate TTL expires. The card
|
||||
// must stay (so the user can retry/deny) and surface the failure (#4062).
|
||||
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('rpc down'));
|
||||
const { store } = renderCard();
|
||||
|
||||
fireEvent.click(screen.getByText('Deny'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.approval_decide',
|
||||
params: { request_id: 'req-connect-1', decision: 'deny' },
|
||||
})
|
||||
);
|
||||
// The parked approval survives the failed decide — not cleared.
|
||||
expect(store.getState().chatRuntime.pendingApprovalByThread[THREAD]).toBeDefined();
|
||||
// The failure is shown rather than silently swallowed.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Could not record your decision/)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('collects required fields inline before authorizing (whatsapp waba_id)', async () => {
|
||||
vi.mocked(authorize).mockResolvedValue({
|
||||
connectUrl: 'https://hosted.composio.dev/wa',
|
||||
connectionId: 'conn-wa',
|
||||
} as Awaited<ReturnType<typeof authorize>>);
|
||||
// Not connected yet — poll keeps waiting; we only assert the authorize args.
|
||||
vi.mocked(listConnections).mockResolvedValue({ connections: [] } as Awaited<
|
||||
ReturnType<typeof listConnections>
|
||||
>);
|
||||
|
||||
const waApproval: PendingApproval = {
|
||||
requestId: 'req-wa',
|
||||
toolName: 'composio_connect',
|
||||
message: 'Connect whatsapp to complete your task',
|
||||
toolkit: 'whatsapp',
|
||||
};
|
||||
const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
|
||||
store.dispatch(setPendingApprovalForThread({ threadId: THREAD, approval: waApproval }));
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<IntegrationConnectCard threadId={THREAD} approval={waApproval} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
// The required field is rendered inline.
|
||||
expect(screen.getByText('WhatsApp Business Account ID (WABA ID)')).toBeInTheDocument();
|
||||
|
||||
// Connecting without filling it blocks authorize and shows a field error.
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('This field is required.')).toBeInTheDocument();
|
||||
|
||||
// Filling it forwards the value as an extra_param to authorize.
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '123456789012345' } });
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
await waitFor(() =>
|
||||
expect(authorize).toHaveBeenCalledWith('whatsapp', { waba_id: '123456789012345' })
|
||||
);
|
||||
});
|
||||
|
||||
it('canonicalizes the toolkit slug before authorizing (google_drive → googledrive)', async () => {
|
||||
vi.mocked(authorize).mockResolvedValueOnce({
|
||||
connectUrl: 'https://hosted.composio.dev/gd',
|
||||
connectionId: 'conn-gd',
|
||||
} as Awaited<ReturnType<typeof authorize>>);
|
||||
vi.mocked(listConnections).mockResolvedValue({ connections: [] } as Awaited<
|
||||
ReturnType<typeof listConnections>
|
||||
>);
|
||||
|
||||
const gdApproval: PendingApproval = {
|
||||
requestId: 'req-gd',
|
||||
toolName: 'composio_connect',
|
||||
message: 'Connect googledrive to complete your task',
|
||||
toolkit: 'google_drive',
|
||||
};
|
||||
const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
|
||||
store.dispatch(setPendingApprovalForThread({ threadId: THREAD, approval: gdApproval }));
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<IntegrationConnectCard threadId={THREAD} approval={gdApproval} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
// The card hits the canonical Composio slug, not the agent's guess.
|
||||
await waitFor(() => expect(authorize).toHaveBeenCalledWith('googledrive', undefined));
|
||||
});
|
||||
|
||||
it('shows an error and a Retry affordance when authorize fails', async () => {
|
||||
vi.mocked(authorize).mockRejectedValueOnce(new Error('backend down'));
|
||||
renderCard();
|
||||
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
// Raw error text is not surfaced; the localized connection-failed string is.
|
||||
await waitFor(() => expect(screen.getByText('Retry connection')).toBeInTheDocument());
|
||||
// The parked call is NOT resolved on a local authorize failure — the user
|
||||
// can retry without the agent giving up.
|
||||
expect(callCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the backend reason and drops Retry on a permanent rejection', async () => {
|
||||
vi.mocked(authorize).mockRejectedValueOnce(
|
||||
new Error(
|
||||
'[composio] authorize failed: Backend returned 400 Bad Request: No auth config found for toolkit "googledrive"'
|
||||
)
|
||||
);
|
||||
renderCard();
|
||||
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
// The actual backend reason is shown (diagnosable), not a bare "failed".
|
||||
await waitFor(() => expect(screen.getByText(/No auth config found/)).toBeInTheDocument());
|
||||
// Retry is gone (it would loop); only Dismiss remains.
|
||||
expect(screen.queryByText('Retry connection')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Deny')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the status when polling finds an errored connection', async () => {
|
||||
vi.mocked(authorize).mockResolvedValueOnce({
|
||||
connectUrl: 'https://hosted.composio.dev/e',
|
||||
connectionId: 'conn-e',
|
||||
} as Awaited<ReturnType<typeof authorize>>);
|
||||
vi.mocked(listConnections).mockResolvedValue({
|
||||
connections: [{ toolkit: 'gmail', status: 'FAILED' }],
|
||||
} as Awaited<ReturnType<typeof listConnections>>);
|
||||
vi.mocked(deriveComposioState).mockReturnValue('error');
|
||||
|
||||
renderCard();
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Connection failed/)).toBeInTheDocument());
|
||||
// A poll-detected error leaves the card for the user to retry/dismiss;
|
||||
// it does NOT auto-resolve the gate.
|
||||
expect(callCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows "additional config required" when authorize reports missing fields for a field-less toolkit', async () => {
|
||||
vi.mocked(authorize).mockRejectedValueOnce(
|
||||
new Error('400: ConnectedAccount_MissingRequiredFields')
|
||||
);
|
||||
renderCard(); // gmail has no entry in the required-fields registry
|
||||
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
// Error line renders as "⚠ {msg}", so match the substring.
|
||||
await waitFor(() => expect(screen.getByText(/Additional config required/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('approves when any matching row is ACTIVE even behind a stale FAILED row', async () => {
|
||||
vi.mocked(authorize).mockResolvedValueOnce({
|
||||
connectUrl: 'https://hosted.composio.dev/m',
|
||||
connectionId: 'conn-m',
|
||||
} as Awaited<ReturnType<typeof authorize>>);
|
||||
// First row is an old FAILED handoff; the freshly-authorized row is ACTIVE.
|
||||
vi.mocked(listConnections).mockResolvedValue({
|
||||
connections: [
|
||||
{ toolkit: 'gmail', status: 'FAILED' },
|
||||
{ toolkit: 'gmail', status: 'ACTIVE' },
|
||||
],
|
||||
} as Awaited<ReturnType<typeof listConnections>>);
|
||||
vi.mocked(deriveComposioState).mockImplementation((c?: { status: string }) =>
|
||||
c?.status === 'ACTIVE' ? 'connected' : 'error'
|
||||
);
|
||||
vi.mocked(callCoreRpc).mockResolvedValue({});
|
||||
|
||||
renderCard();
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
// The ACTIVE row wins over the stale FAILED row → approve.
|
||||
await waitFor(() =>
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.approval_decide',
|
||||
params: { request_id: 'req-connect-1', decision: 'approve_once' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts the authorize continuation if the card is dismissed mid-flight', async () => {
|
||||
let resolveAuthorize!: (v: Awaited<ReturnType<typeof authorize>>) => void;
|
||||
vi.mocked(authorize).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveAuthorize = resolve;
|
||||
})
|
||||
);
|
||||
vi.mocked(callCoreRpc).mockResolvedValue({});
|
||||
|
||||
renderCard();
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
// Deny while authorize is still in flight.
|
||||
fireEvent.click(screen.getByText('Deny'));
|
||||
await waitFor(() =>
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.approval_decide',
|
||||
params: { request_id: 'req-connect-1', decision: 'deny' },
|
||||
})
|
||||
);
|
||||
|
||||
// authorize finally resolves — the continuation must NOT open OAuth.
|
||||
resolveAuthorize({
|
||||
connectUrl: 'https://hosted.composio.dev/x',
|
||||
connectionId: 'conn-x',
|
||||
} as Awaited<ReturnType<typeof authorize>>);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the gate as deny when the OAuth poll times out', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(authorize).mockResolvedValueOnce({
|
||||
connectUrl: 'https://hosted.composio.dev/t',
|
||||
connectionId: 'conn-t',
|
||||
} as Awaited<ReturnType<typeof authorize>>);
|
||||
// Never connects → poll runs until the 5-min deadline.
|
||||
vi.mocked(listConnections).mockResolvedValue({ connections: [] } as Awaited<
|
||||
ReturnType<typeof listConnections>
|
||||
>);
|
||||
vi.mocked(callCoreRpc).mockResolvedValue({});
|
||||
|
||||
renderCard();
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
// Flush authorize + run polling past the 5-min deadline.
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5000);
|
||||
|
||||
// Timeout resolves the parked tool call as deny so the agent resumes.
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.approval_decide',
|
||||
params: { request_id: 'req-connect-1', decision: 'deny' },
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1022,7 +1022,7 @@ function RequiredFieldsForm({
|
||||
value={value}
|
||||
autoFocus={autoFocusFirst && idx === 0}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
placeholder={field.placeholderKey ? t(field.placeholderKey) : undefined}
|
||||
aria-describedby={hintId}
|
||||
aria-invalid={!!errorText}
|
||||
className="flex-1 min-w-0 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 bg-transparent focus:outline-none"
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('toolkitRequiredFields registry', () => {
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].key).toBe('org_name');
|
||||
expect(fields[0].suffix).toBe('.crm.dynamics.com');
|
||||
expect(fields[0].placeholder).toBe('myorg');
|
||||
expect(fields[0].placeholderKey).toBe('composio.connect.dynamicsOrgNamePlaceholder');
|
||||
});
|
||||
|
||||
it('exposes the Jira subdomain field with the .atlassian.net suffix', () => {
|
||||
|
||||
@@ -32,8 +32,12 @@ export interface ToolkitRequiredField {
|
||||
labelKey: TranslationKey;
|
||||
/** Optional i18n key for the hint paragraph rendered below the input. */
|
||||
hintKey?: TranslationKey;
|
||||
/** Optional placeholder text shown when the input is empty. */
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Optional i18n key for the placeholder shown when the input is empty.
|
||||
* Keyed (not raw text) so the placeholder stays inside the i18n pipeline
|
||||
* like `labelKey` / `hintKey` — all UI text goes through `useT()`.
|
||||
*/
|
||||
placeholderKey?: TranslationKey;
|
||||
/**
|
||||
* Optional fixed suffix rendered inside the input (e.g. `.atlassian.net`).
|
||||
* Purely cosmetic — never included in the submitted value.
|
||||
@@ -73,7 +77,7 @@ export const TOOLKIT_REQUIRED_FIELDS: Readonly<Record<string, readonly ToolkitRe
|
||||
key: 'waba_id',
|
||||
labelKey: 'composio.connect.wabaIdLabel',
|
||||
hintKey: 'composio.connect.wabaIdHint',
|
||||
placeholder: 'e.g. 123456789012345',
|
||||
placeholderKey: 'composio.connect.wabaIdPlaceholder',
|
||||
},
|
||||
],
|
||||
jira: [
|
||||
@@ -81,7 +85,7 @@ export const TOOLKIT_REQUIRED_FIELDS: Readonly<Record<string, readonly ToolkitRe
|
||||
key: 'subdomain',
|
||||
labelKey: 'composio.connect.atlassianSubdomainLabel',
|
||||
hintKey: 'composio.connect.atlassianSubdomainHint',
|
||||
placeholder: 'your-subdomain',
|
||||
placeholderKey: 'composio.connect.atlassianSubdomainPlaceholder',
|
||||
suffix: '.atlassian.net',
|
||||
validate: validateSubdomainLabel,
|
||||
},
|
||||
@@ -91,7 +95,7 @@ export const TOOLKIT_REQUIRED_FIELDS: Readonly<Record<string, readonly ToolkitRe
|
||||
key: 'org_name',
|
||||
labelKey: 'composio.connect.dynamicsOrgNameLabel',
|
||||
hintKey: 'composio.connect.dynamicsOrgNameHint',
|
||||
placeholder: 'myorg',
|
||||
placeholderKey: 'composio.connect.dynamicsOrgNamePlaceholder',
|
||||
suffix: '.crm.dynamics.com',
|
||||
validate: validateSubdomainLabel,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ const TOOLKIT_ALIASES: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function canonicalizeComposioToolkitSlug(slug: string): string {
|
||||
const key = slug.toLowerCase();
|
||||
// `.trim()` keeps this in sync with the Rust `canonicalize_toolkit_slug`
|
||||
// (src/openhuman/composio/tools.rs) so a stray-whitespace slug can't diverge.
|
||||
const key = slug.trim().toLowerCase();
|
||||
return TOOLKIT_ALIASES[key] ?? key;
|
||||
}
|
||||
|
||||
@@ -2939,6 +2939,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'احصل عليه عبر GET /me/businesses ثم GET /{business_id}/owned_whatsapp_business_accounts باستخدام رمز وصول Meta الخاص بك.',
|
||||
'composio.connect.wabaIdLabel': 'تسمية معرف WABA',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired': 'يرجى إدخال معرف حساب WhatsApp Business (WABA ID) للمتابعة.',
|
||||
'composio.connect.waitingFor': 'بانتظار',
|
||||
'composio.connect.waitingHint': 'تلميح الانتظار',
|
||||
|
||||
@@ -3004,6 +3004,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'আপনার Meta অ্যাক্সেস টোকেন ব্যবহার করে GET /me/businesses তারপর GET /{business_id}/owned_whatsapp_business_accounts এর মাধ্যমে এটি খুঁজে পান।',
|
||||
'composio.connect.wabaIdLabel': 'WABA ID লেবেল',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'চালিয়ে যেতে আপনার WhatsApp Business Account ID (WABA ID) দিন।',
|
||||
'composio.connect.waitingFor': 'অপেক্ষা করছে',
|
||||
|
||||
@@ -3075,6 +3075,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Finde es über GET /me/businesses und dann GET /{business_id}/owned_whatsapp_business_accounts mit deinem Meta-Zugriffstoken.',
|
||||
'composio.connect.wabaIdLabel': 'Waba-ID-Etikett',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Bitte gib deine WhatsApp Geschäftskonto-ID (WABA ID) ein, um fortzufahren.',
|
||||
'composio.connect.waitingFor': 'Warten auf',
|
||||
|
||||
@@ -3566,6 +3566,9 @@ const en: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Find it via GET /me/businesses then GET /{business_id}/owned_whatsapp_business_accounts using your Meta access token.',
|
||||
'composio.connect.wabaIdLabel': 'WhatsApp Business Account ID (WABA ID)',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Please enter your WhatsApp Business Account ID (WABA ID) to continue.',
|
||||
'composio.connect.waitingFor': 'Waiting for',
|
||||
|
||||
@@ -3053,6 +3053,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Encuéntralo mediante GET /me/businesses y luego GET /{business_id}/owned_whatsapp_business_accounts usando tu token de acceso de Meta.',
|
||||
'composio.connect.wabaIdLabel': 'Etiqueta de ID de WABA',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Ingresa tu ID de cuenta de WhatsApp Business (WABA ID) para continuar.',
|
||||
'composio.connect.waitingFor': 'Esperando a',
|
||||
|
||||
@@ -3068,6 +3068,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
"Trouve-le via GET /me/businesses puis GET /{business_id}/owned_whatsapp_business_accounts en utilisant ton jeton d'accès Meta.",
|
||||
'composio.connect.wabaIdLabel': "Libellé de l'identifiant WABA",
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Saisis ton identifiant WhatsApp Business Account (WABA ID) pour continuer.',
|
||||
'composio.connect.waitingFor': 'En attente de',
|
||||
|
||||
@@ -3004,6 +3004,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'अपने Meta एक्सेस टोकन का उपयोग करके GET /me/businesses फिर GET /{business_id}/owned_whatsapp_business_accounts के माध्यम से इसे प्राप्त करें।',
|
||||
'composio.connect.wabaIdLabel': 'Waba आईडी लेबल',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'जारी रखने के लिए अपना WhatsApp Business Account ID (WABA ID) डालें।',
|
||||
'composio.connect.waitingFor': 'प्रतीक्षा कर रहा है',
|
||||
|
||||
@@ -3007,6 +3007,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Temukan melalui GET /me/businesses lalu GET /{business_id}/owned_whatsapp_business_accounts menggunakan token akses Meta Anda.',
|
||||
'composio.connect.wabaIdLabel': 'Label ID WABA',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Masukkan ID Akun Bisnis WhatsApp (WABA ID) Anda untuk melanjutkan.',
|
||||
'composio.connect.waitingFor': 'Menunggu',
|
||||
|
||||
@@ -3047,6 +3047,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Trovalo tramite GET /me/businesses poi GET /{business_id}/owned_whatsapp_business_accounts usando il tuo token di accesso Meta.',
|
||||
'composio.connect.wabaIdLabel': 'Etichetta WABA ID',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Inserisci il tuo ID WhatsApp Business Account (WABA ID) per continuare.',
|
||||
'composio.connect.waitingFor': 'In attesa di',
|
||||
|
||||
@@ -2977,6 +2977,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'GET /me/businesses를 통해 찾은 다음 Meta 액세스 토큰을 사용하여 /{business_id}/owned_whatsapp_business_accounts를 GET하세요.',
|
||||
'composio.connect.wabaIdLabel': 'WABA ID 라벨',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'계속하려면 WhatsApp Business Account ID (WABA ID)를 입력하세요.',
|
||||
'composio.connect.waitingFor': '대기 중:',
|
||||
|
||||
@@ -3034,6 +3034,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Znajdziesz je przez GET /me/businesses, a następnie GET /{business_id}/owned_whatsapp_business_accounts z tokenem dostępu Meta.',
|
||||
'composio.connect.wabaIdLabel': 'ID WhatsApp Business Account',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Wprowadź swoje ID WhatsApp Business Account (WABA ID), aby kontynuować.',
|
||||
'composio.connect.waitingFor': 'Oczekiwanie na',
|
||||
|
||||
@@ -3052,6 +3052,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Encontre-o via GET /me/businesses depois GET /{business_id}/owned_whatsapp_business_accounts usando seu token de acesso do Meta.',
|
||||
'composio.connect.wabaIdLabel': 'Rótulo de ID WABA',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Por favor, insira seu ID de Conta Empresarial do WhatsApp (WABA ID) para continuar.',
|
||||
'composio.connect.waitingFor': 'Aguardando',
|
||||
|
||||
@@ -3026,6 +3026,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'Найдите его через GET /me/businesses, затем GET /{business_id}/owned_whatsapp_business_accounts, используя ваш токен доступа Meta.',
|
||||
'composio.connect.wabaIdLabel': 'ID аккаунта WhatsApp Business',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired':
|
||||
'Введи ID аккаунта WhatsApp Business (WABA ID) для продолжения.',
|
||||
'composio.connect.waitingFor': 'Ожидание',
|
||||
|
||||
@@ -2857,6 +2857,9 @@ const messages: TranslationMap = {
|
||||
'composio.connect.wabaIdHint':
|
||||
'通过 Meta 访问令牌调用 GET /me/businesses,然后 GET /{business_id}/owned_whatsapp_business_accounts 获取。',
|
||||
'composio.connect.wabaIdLabel': 'WhatsApp 企业账户 ID',
|
||||
'composio.connect.atlassianSubdomainPlaceholder': 'your-subdomain',
|
||||
'composio.connect.dynamicsOrgNamePlaceholder': 'myorg',
|
||||
'composio.connect.wabaIdPlaceholder': 'e.g. 123456789012345',
|
||||
'composio.connect.wabaIdRequired': '请输入你的 WhatsApp 企业账户 ID(WABA ID)以继续。',
|
||||
'composio.connect.waitingFor': '等待中',
|
||||
'composio.connect.waitingHint': '请在浏览器中完成授权',
|
||||
|
||||
@@ -11,6 +11,7 @@ import ChatComposer from '../components/chat/ChatComposer';
|
||||
import ChatFilesChip from '../components/chat/ChatFilesChip';
|
||||
import ChatNewWindowHero from '../components/chat/ChatNewWindowHero';
|
||||
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
|
||||
import IntegrationConnectCard from '../components/chat/IntegrationConnectCard';
|
||||
import QueuedFollowups from '../components/chat/QueuedFollowups';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
@@ -2527,11 +2528,31 @@ const Conversations = ({
|
||||
const pendingApproval = approvalThreadId
|
||||
? pendingApprovalByThread[approvalThreadId]
|
||||
: undefined;
|
||||
return pendingApproval && approvalThreadId ? (
|
||||
if (!pendingApproval || !approvalThreadId) return null;
|
||||
// `composio_connect` parks on the same gate but needs a Connect
|
||||
// button + OAuth poll rather than approve/deny (#3993).
|
||||
const isConnect = pendingApproval.toolName === 'composio_connect';
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<ApprovalRequestCard threadId={approvalThreadId} approval={pendingApproval} />
|
||||
{isConnect ? (
|
||||
// Key by requestId so switching from one parked approval to
|
||||
// another remounts the card with fresh local state (phase,
|
||||
// field values, cancellation refs, poll timers) instead of
|
||||
// bleeding the previous request's state in (#4062, coderabbit).
|
||||
<IntegrationConnectCard
|
||||
key={pendingApproval.requestId}
|
||||
threadId={approvalThreadId}
|
||||
approval={pendingApproval}
|
||||
/>
|
||||
) : (
|
||||
<ApprovalRequestCard
|
||||
key={pendingApproval.requestId}
|
||||
threadId={approvalThreadId}
|
||||
approval={pendingApproval}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
|
||||
@@ -961,6 +961,9 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
firstString(a.path) ??
|
||||
firstString(a.url) ??
|
||||
firstString(a.target);
|
||||
// `composio_connect` carries the toolkit slug so the inline connect
|
||||
// card (#3993) knows which integration to authorize.
|
||||
const toolkit = firstString(a.toolkit);
|
||||
dispatch(
|
||||
setPendingApprovalForThread({
|
||||
threadId: event.thread_id,
|
||||
@@ -969,6 +972,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
toolName: event.tool_name,
|
||||
message: event.message,
|
||||
command,
|
||||
toolkit,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -221,6 +221,13 @@ export interface PendingApproval {
|
||||
* extracted from the event's redacted args for display. Empty if unavailable.
|
||||
*/
|
||||
command?: string;
|
||||
/**
|
||||
* Toolkit slug carried on `composio_connect` requests (#3993). Present only
|
||||
* when `toolName === 'composio_connect'`; the inline connect card uses it to
|
||||
* run the OAuth handoff and poll for completion. The slug is a public
|
||||
* identifier (not PII), so it survives arg redaction unchanged.
|
||||
*/
|
||||
toolkit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,11 @@ named = [
|
||||
"composio_list_tools",
|
||||
"file_read",
|
||||
"composio_execute",
|
||||
# Inline-in-chat OAuth connect card (#3993). When the bound toolkit is not
|
||||
# connected — or an action fails with a true auth/connection error — raise
|
||||
# the connect card with this tool and await the result, instead of bubbling
|
||||
# up "Connection error" and sending the user to Settings → Connections.
|
||||
"composio_connect",
|
||||
# Deterministic time resolver. Composio actions take time-window args
|
||||
# (Slack/Gmail/Calendar `oldest`/`latest`/`since`/`after`) as raw
|
||||
# timestamps; the model must NEVER hand-compute epoch seconds (a real
|
||||
|
||||
@@ -6,6 +6,7 @@ You are the **Integrations Agent**. You interact with one connected external ser
|
||||
|
||||
- **`composio_list_tools`** — inspect the action catalogue for your bound toolkit. Returns the `function.name` slug + JSON schema for each action.
|
||||
- **`composio_execute`** — run a Composio action: `{ tool: "<SLUG>", arguments: {...} }`.
|
||||
- **`composio_connect`** — raise an **inline connect card** in the chat for your bound toolkit and wait for the user to authorize in one click. Use this the moment you detect the toolkit is not connected, or after a true auth/connection error. Never tell the user to open Settings → Connections yourself while this tool is available.
|
||||
- **`extract_from_result`** — runtime-provided system tool for oversized-result runs. Use it when a tool returned too much data to inspect directly: pass the prior `result_id` plus a narrow `query`, and it will return only the requested slice from that oversized result.
|
||||
- **Per-action tools** — the toolkit's individual action tools are already registered in your tool list with typed schemas (e.g. `GMAIL_SEND_EMAIL`, `NOTION_CREATE_PAGE`). Prefer calling these directly over the generic `composio_execute`.
|
||||
|
||||
@@ -13,17 +14,21 @@ You do **not** have shell, file I/O, or any other capability beyond these permit
|
||||
|
||||
## Typical flow
|
||||
|
||||
0. **Connect first if needed.** If the caller's objective is simply to connect/authorize this toolkit, or you already know it isn't connected, call `composio_connect { toolkit }`, await the result, and report it. `{ connected: true }` → proceed (or you're done, if connecting was the whole task); `{ connected: false }` → the user declined: report that plainly, note they can still connect later via Settings → Connections, and stop — do **not** retry `composio_connect`.
|
||||
1. You already have the toolkit's action tools in your tool list — start there. If you need a schema reminder or a slug you don't see, call `composio_list_tools`.
|
||||
2. Call the per-action tool (or `composio_execute` with the slug) using the caller's task as your guide.
|
||||
3. If the call fails with `[composio:error:insufficient_scope]`, `insufficient authentication scopes`, or `missing required permissions`, do **not** call the service disconnected. Say the connected account is missing the permissions needed for the requested action and point the user to Settings → Connections → the toolkit to reconnect or enable the required scope.
|
||||
4. If the call fails with a true authentication / authorization / connection error that is **not** a scope or permission error, stop and return: **"Connection error, try to authenticate"** — the orchestrator will take over and route the user to settings.
|
||||
4. If the call fails with a true authentication / authorization / connection error that is **not** a scope or permission error, the toolkit is not connected. Call **`composio_connect`** with your bound `toolkit` to raise an inline connect card and **await its result**:
|
||||
- `{ connected: true }` → the user authorized; retry the original action **once** and continue.
|
||||
- `{ connected: false, declined: true }` (or an error) → the user declined or the card could not be raised. **Only then** return **"Connection error, try to authenticate"** so the orchestrator can route the user to settings.
|
||||
Do **not** print a Settings → Connections instruction yourself when `composio_connect` is available.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never fabricate action slugs.** Pull them from `composio_list_tools` or use the per-action tools already in your list.
|
||||
- **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly.
|
||||
- **Scope errors are not disconnections.** If Gmail or another connected toolkit returns insufficient scope / missing permissions, report the missing permission plainly and direct the user to Settings → Connections → that toolkit. Never say the toolkit is disconnected for this case.
|
||||
- **Auth errors bubble up.** On true auth / connection failures only, reply exactly: `Connection error, try to authenticate`. Do not retry, do not attempt to re-authorise yourself — you have no tools for that.
|
||||
- **Auth errors → connect inline first.** On a true auth / connection failure (not a scope error), call `composio_connect { toolkit }` to raise the inline connect card and await it. If it returns `connected: true`, retry the action once. Only if the user declines or the card can't be raised, reply exactly: `Connection error, try to authenticate`. Never paste OAuth URLs or name Composio to the user.
|
||||
- **Be precise** — every action expects a specific argument shape. Validate against the schema before calling.
|
||||
- **Report results** — state what action was taken and the outcome, including any cost reported by Composio.
|
||||
|
||||
|
||||
@@ -171,12 +171,14 @@ hint = "chat"
|
||||
# Direct tools — things the orchestrator calls itself rather than
|
||||
# delegating.
|
||||
#
|
||||
# `composio_list_connections` is the orchestrator's only composio_*
|
||||
# tool: it exists so the agent can detect newly-authorised integrations
|
||||
# mid-session (the session-start fetch froze the Delegation Guide's
|
||||
# connected list). Authorisation, toolkit discovery, action listing,
|
||||
# and action execution all live downstream in `integrations_agent` —
|
||||
# the orchestrator never calls composio_authorize / composio_list_tools
|
||||
# `composio_list_connections` lets the agent detect newly-authorised
|
||||
# integrations mid-session (the session-start fetch froze the Delegation
|
||||
# Guide's connected list). `composio_connect` lets it raise an inline
|
||||
# connect card for ANY toolkit the user asks to connect — including ones
|
||||
# not yet connected (#3993); the tool validates the slug against the
|
||||
# backend allowlist and the OAuth handoff itself runs inside the card.
|
||||
# Toolkit *action* listing and execution still live downstream in
|
||||
# `integrations_agent` — the orchestrator never calls composio_list_tools
|
||||
# / composio_execute directly.
|
||||
named = [
|
||||
"read_workspace_state",
|
||||
@@ -190,6 +192,11 @@ named = [
|
||||
"spawn_async_subagent",
|
||||
"spawn_parallel_agents",
|
||||
"composio_list_connections",
|
||||
# Inline OAuth connect card (#3993). Free-form `toolkit` slug — the
|
||||
# orchestrator calls this directly to connect a service the user names,
|
||||
# whether or not it is already connected. Validates against the backend
|
||||
# allowlist and raises the in-chat Connect card.
|
||||
"composio_connect",
|
||||
# Lightweight MCP discovery — the orchestrator's only `mcp_registry_*`
|
||||
# tool, mirroring `composio_list_connections`. Lists the user's installed
|
||||
# MCP servers and their live connection state + tool counts so the agent
|
||||
|
||||
@@ -26,7 +26,7 @@ Follow this sequence for every user message:
|
||||
- Words like "email/inbox/gmail", "calendar", "notion doc", "drive file", "slack/whatsapp/telegram message", "linear ticket", "send to X", "check X", etc. mean the user wants the **live** service.
|
||||
- Find the matching toolkit in the **Connected Integrations** section and call `delegate_to_integrations_agent` with that `toolkit`.
|
||||
- **Do this even if `memory_tree` could plausibly answer.** The user wants the live source of truth, not a stale summary.
|
||||
- If the relevant toolkit is not in **Connected Integrations**, tell the user to connect it via Settings → Connections → [Service] (see "Connecting external services" below). Do **not** silently fall back to `memory_tree`.
|
||||
- If the relevant toolkit is **not** in **Connected Integrations**, call `composio_connect { toolkit: "<slug>" }` **directly** to raise an **inline connect card** so the user can authorize in one click, then continue the task once it returns `connected: true`. Do **not** refuse based on the Connected Integrations list (that is only what is *already* connected, not what is *connectable*), do **not** make "go to Settings → Connections" your first move, and do **not** silently fall back to `memory_tree` (see "Connecting external services" below).
|
||||
3. **Can I solve this with direct tools?**
|
||||
- Yes: use direct tools (`query_memory`, `read_workspace_state`, `composio_list_connections`, task tools, etc.).
|
||||
- No: continue.
|
||||
@@ -102,7 +102,9 @@ When the user asks to connect a service (Gmail, Notion, WhatsApp, Calendar, Driv
|
||||
|
||||
- **Never** paste external URLs (e.g. `app.composio.dev`, provider OAuth pages, dashboards).
|
||||
- **Never** explain OAuth, Composio, or any backend mechanic by name.
|
||||
- Reply with one short bubble pointing to the in-app path: **Settings → Connections → [Service]**. Example: `head to Settings → Connections → Gmail to hook it up, ping me when it's connected`.
|
||||
- **Connect inline, don't redirect.** Call `composio_connect { toolkit: "<slug>" }` **directly** to raise an **inline connect card** in the chat — this works for **any** service the user names (gmail, notion, whatsapp, youtube, …), not just ones already connected. The card *is* the confirmation: when the user asks to connect/authorize a service, or wants to use one that isn't connected, just call `composio_connect` — don't ask "want me to raise a card?" first. The user authorizes in one click and the task continues in the same turn.
|
||||
- **Don't confabulate "unsupported".** You do **not** have the list of connectable toolkits in your prompt — only the *connected* ones. Never tell the user a service "isn't available to connect" from memory. `composio_connect` checks the real backend allowlist: if it returns that the toolkit isn't an available integration, relay that message (and the list it provides). That is the only honest "I can't connect this".
|
||||
- **On decline / fallback.** If `composio_connect` reports the user declined (`connected: false`) or that it couldn't raise the card, acknowledge it and offer `head to Settings → Connections → [Service]` as the alternative.
|
||||
- If the user already said they connected it, call `composio_list_connections` to verify before continuing.
|
||||
- Do **not** apply this rule to scope / permission failures such as `[composio:error:insufficient_scope]` or "missing required permissions". For those, say the connection exists but needs additional permissions in **Settings → Connections → [Service]**.
|
||||
|
||||
|
||||
@@ -183,6 +183,15 @@ fn match_home_prefix(s: &str) -> Option<usize> {
|
||||
/// the user knows *what* the agent wants to do without exposing the
|
||||
/// content.
|
||||
pub fn summarize_action(tool_name: &str, args: &Value) -> String {
|
||||
// Friendly, human-readable summaries for tools whose approval card reads
|
||||
// better as a sentence than a `key=value` dump (#3993). `entry_id` is a
|
||||
// public catalog slug, not PII, so it is safe to surface verbatim.
|
||||
if tool_name == "skill_registry_install" {
|
||||
if let Some(id) = args.get("entry_id").and_then(|v| v.as_str()) {
|
||||
return format!("Install the \"{id}\" skill to complete your task");
|
||||
}
|
||||
}
|
||||
|
||||
let safe_fields: &[&str] = &[
|
||||
"action",
|
||||
"tool_slug",
|
||||
@@ -444,4 +453,36 @@ mod tests {
|
||||
assert!(summary.contains("pushover"));
|
||||
assert!(summary.contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_action_skill_install_is_human_readable() {
|
||||
let args = json!({ "entry_id": "notion" });
|
||||
let summary = summarize_action("skill_registry_install", &args);
|
||||
// Friendly sentence, not a key=value/byte dump (#3993).
|
||||
assert_eq!(
|
||||
summary,
|
||||
"Install the \"notion\" skill to complete your task"
|
||||
);
|
||||
assert!(!summary.contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_preserves_toolkit_slug_for_connect_card() {
|
||||
// The inline connect card (#3993) reads `toolkit` out of the redacted
|
||||
// args to drive the OAuth handoff, so a non-sensitive slug must survive
|
||||
// redaction verbatim while real PII alongside it is still scrubbed.
|
||||
let args = json!({ "toolkit": "gmail", "body": "secret message" });
|
||||
let redacted = redact_args(&args);
|
||||
assert_eq!(redacted["toolkit"], json!("gmail"));
|
||||
assert_ne!(redacted["body"], json!("secret message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_action_skill_install_without_entry_id_falls_back() {
|
||||
let args = json!({});
|
||||
let summary = summarize_action("skill_registry_install", &args);
|
||||
// Missing slug → generic fallback so we never panic or mislabel.
|
||||
assert!(summary.contains("skill_registry_install"));
|
||||
assert!(summary.contains("bytes"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +666,277 @@ impl Tool for ComposioAuthorizeTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── composio_connect (inline approval card, #3993) ──────────────────
|
||||
|
||||
/// Canonicalize an agent/user-supplied toolkit slug to the form Composio's
|
||||
/// backend expects. Mirrors `canonicalizeComposioToolkitSlug` on the FE
|
||||
/// (`app/src/lib/composio/toolkitSlug.ts`) — **keep the alias maps in sync**.
|
||||
/// The agent frequently guesses `google_drive` where Composio uses
|
||||
/// `googledrive` (#3993); without this the OAuth handoff fails with an opaque
|
||||
/// error.
|
||||
fn canonicalize_toolkit_slug(slug: &str) -> String {
|
||||
let key = slug.trim().to_ascii_lowercase();
|
||||
match key.as_str() {
|
||||
"feishu" | "lark" => "larksuite".to_string(),
|
||||
"google_calendar" => "googlecalendar".to_string(),
|
||||
"google_drive" => "googledrive".to_string(),
|
||||
"google_sheets" => "googlesheets".to_string(),
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh (uncached) liveness check for `toolkit`.
|
||||
///
|
||||
/// Tri-state via `Result`:
|
||||
/// - `Ok(true)` — a connection is verified ACTIVE.
|
||||
/// - `Ok(false)` — the read succeeded but no ACTIVE connection exists.
|
||||
/// - `Err(_)` — the state could **not** be verified (client construction or
|
||||
/// the list call failed).
|
||||
///
|
||||
/// Used to confirm liveness after the approval gate resolves `Allow`. The
|
||||
/// card-driven path only approves once it has polled the connection ACTIVE,
|
||||
/// but other approval surfaces (a typed `yes`, Telegram's approval prompt, or
|
||||
/// an existing auto-approve entry) resolve `Allow` with no OAuth poll — so
|
||||
/// `Allow` alone must NOT be treated as "connected" (#3993, codex review).
|
||||
///
|
||||
/// Distinguishing `Err` from `Ok(false)` lets the caller fail closed on a
|
||||
/// transient backend/auth failure **without** fabricating an "OAuth not
|
||||
/// complete" reason that wrongly blames the user (#4062, coderabbit review).
|
||||
async fn connection_is_active(config: &Config, toolkit: &str) -> anyhow::Result<bool> {
|
||||
let active_match = |connections: &[super::types::ComposioConnection]| {
|
||||
connections
|
||||
.iter()
|
||||
.any(|c| c.is_active() && c.normalized_toolkit().eq_ignore_ascii_case(toolkit))
|
||||
};
|
||||
match create_composio_client(config)? {
|
||||
ComposioClientKind::Backend(client) => {
|
||||
Ok(active_match(&client.list_connections().await?.connections))
|
||||
}
|
||||
ComposioClientKind::Direct(direct) => Ok(active_match(
|
||||
&direct_list_connections(&direct).await?.connections,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect a Composio integration **inline in the chat** instead of
|
||||
/// sending the user off to Settings → Connections.
|
||||
///
|
||||
/// Unlike [`ComposioAuthorizeTool`] (which hands the agent a raw
|
||||
/// `connectUrl` it is not allowed to paste), this tool raises an
|
||||
/// approval card via the process-global `ApprovalGate`. The frontend
|
||||
/// recognises `tool_name == "composio_connect"` and renders a **Connect**
|
||||
/// button: clicking it runs the existing `composio_authorize` RPC, opens
|
||||
/// the OAuth handoff, and polls `composio_list_connections` until the
|
||||
/// toolkit is ACTIVE — at which point it resolves the gate. The agent
|
||||
/// then resumes the original task in the same turn.
|
||||
pub struct ComposioConnectTool {
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl ComposioConnectTool {
|
||||
pub fn new(config: Arc<Config>) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ComposioConnectTool {
|
||||
fn name(&self) -> &str {
|
||||
"composio_connect"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Connect a Composio integration (OAuth) for the user **inline in the chat**. \
|
||||
Raises an approval card with a Connect button — the user authorizes in one \
|
||||
click without leaving the conversation, and this tool returns once the \
|
||||
connection is active (or the user declines). ALWAYS prefer this over telling \
|
||||
the user to open Settings → Connections. Returns {toolkit, connected}."
|
||||
}
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"toolkit": {
|
||||
"type": "string",
|
||||
"description": "Toolkit slug to connect, e.g. 'gmail' or 'notion'."
|
||||
}
|
||||
},
|
||||
"required": ["toolkit"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Write
|
||||
}
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Workflow
|
||||
}
|
||||
// NOTE: `external_effect` deliberately stays `false`. Gating happens
|
||||
// *inside* `execute` via a manual `ApprovalGate` intercept so we can
|
||||
// (a) skip the card when the toolkit is already connected and (b) carry
|
||||
// the toolkit slug into the card for the inline Connect button. The
|
||||
// engine's auto-gate is unconditional and would double-prompt.
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let raw_toolkit = args
|
||||
.get("toolkit")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if raw_toolkit.is_empty() {
|
||||
return Ok(ToolResult::error("composio_connect: 'toolkit' is required"));
|
||||
}
|
||||
// Canonicalize before any backend call — the agent often guesses
|
||||
// `google_drive` where Composio expects `googledrive` (#3993).
|
||||
let toolkit = canonicalize_toolkit_slug(raw_toolkit);
|
||||
tracing::debug!(raw = %raw_toolkit, toolkit = %toolkit, "[composio] tool connect.execute");
|
||||
|
||||
// The inline connect card only has a surface on an interactive chat
|
||||
// turn (the web-chat path installs `APPROVAL_CHAT_CONTEXT`). On
|
||||
// background / cron turns there is no UI to click Connect, so fail
|
||||
// closed with a clear message rather than parking forever — mirrors
|
||||
// the `install_tool` guard (#3993).
|
||||
if crate::openhuman::approval::APPROVAL_CHAT_CONTEXT
|
||||
.try_with(|_| ())
|
||||
.is_err()
|
||||
{
|
||||
return Ok(ToolResult::error(format!(
|
||||
"[policy-denied] composio_connect needs an interactive chat turn. \
|
||||
Ask the user to connect '{toolkit}' in Settings → Connections."
|
||||
)));
|
||||
}
|
||||
|
||||
// Reload config per call so a mid-session `composio.mode` toggle is
|
||||
// honoured (#1710), then skip the card entirely if the toolkit is
|
||||
// already connected — avoids a flash of a Connect card that would
|
||||
// immediately resolve.
|
||||
let live_config =
|
||||
match config_rpc::reload_config_snapshot_with_timeout(self.config.as_ref()).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[composio] connect.execute: load_config failed");
|
||||
self.config.as_ref().clone()
|
||||
}
|
||||
};
|
||||
let already_connected = super::fetch_connected_integrations(&live_config)
|
||||
.await
|
||||
.into_iter()
|
||||
.any(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(&toolkit));
|
||||
if already_connected {
|
||||
tracing::debug!(toolkit = %toolkit, "[composio] connect.execute: already connected");
|
||||
return Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"toolkit": toolkit,
|
||||
"connected": true,
|
||||
"already_connected": true,
|
||||
}))?));
|
||||
}
|
||||
|
||||
// Validate the toolkit against the *connectable* catalog before raising
|
||||
// a card. The orchestrator only knows which toolkits are already
|
||||
// connected — it must NOT confabulate "unsupported" from that list
|
||||
// (#3993). This grounds the answer: a backend-allowlisted toolkit gets
|
||||
// a card; a genuinely unsupported one gets a clear, listed refusal
|
||||
// instead of a card that would fail on Connect.
|
||||
match create_composio_client(&live_config) {
|
||||
Ok(ComposioClientKind::Backend(client)) => {
|
||||
if let Ok(resp) = client.list_toolkits().await {
|
||||
// Empty allowlist = backend predates the catalog / unknown;
|
||||
// don't block — let the OAuth handoff report support.
|
||||
if !resp.toolkits.is_empty()
|
||||
&& !resp
|
||||
.toolkits
|
||||
.iter()
|
||||
.any(|s| s.eq_ignore_ascii_case(&toolkit))
|
||||
{
|
||||
let available = resp.toolkits.join(", ");
|
||||
tracing::info!(toolkit = %toolkit, "[composio] connect.execute: toolkit not in allowlist");
|
||||
return Ok(ToolResult::error(format!(
|
||||
"composio_connect: '{toolkit}' is not an available integration. \
|
||||
Connectable toolkits: {available}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ComposioClientKind::Direct(_)) => {
|
||||
// Personal-tenant (direct) mode performs OAuth at app.composio.dev,
|
||||
// not via the backend handoff the card drives — so an inline card
|
||||
// can't complete it. Point the user to Settings instead.
|
||||
return Ok(ToolResult::error(format!(
|
||||
"composio_connect: direct Composio mode is active — connect '{toolkit}' \
|
||||
in Settings → Connections (your personal Composio account)."
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"composio_connect: Composio is unavailable: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Raise the inline connect card via the approval gate. The frontend
|
||||
// resolves it with `approve_once` once it polls the connection ACTIVE;
|
||||
// an explicit decline or the 10-minute TTL resolves it as denied.
|
||||
let gate = match crate::openhuman::approval::ApprovalGate::try_global() {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"composio_connect: approval gate unavailable in this environment",
|
||||
));
|
||||
}
|
||||
};
|
||||
let summary = format!("Connect {toolkit} to complete your task");
|
||||
let (outcome, _request_id) = gate
|
||||
.intercept_audited("composio_connect", &summary, json!({ "toolkit": toolkit }))
|
||||
.await;
|
||||
match outcome {
|
||||
crate::openhuman::approval::GateOutcome::Allow => {
|
||||
// `Allow` only means the prompt was approved — re-check liveness
|
||||
// with a fresh read, because non-card approval surfaces (typed
|
||||
// "yes", Telegram, auto-approve) resolve Allow without running
|
||||
// the OAuth poll (#3993, codex review).
|
||||
match connection_is_active(&live_config, &toolkit).await {
|
||||
Ok(true) => {
|
||||
tracing::debug!(toolkit = %toolkit, "[composio] connect.execute: connection active");
|
||||
Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"toolkit": toolkit,
|
||||
"connected": true,
|
||||
}))?))
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::info!(toolkit = %toolkit, "[composio] connect.execute: approved but not yet active");
|
||||
Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"toolkit": toolkit,
|
||||
"connected": false,
|
||||
"reason": "Approved, but the connection is not active yet — the user still needs to complete the OAuth flow.",
|
||||
}))?))
|
||||
}
|
||||
Err(e) => {
|
||||
// Couldn't verify liveness — a transient backend/auth
|
||||
// failure, NOT proof the user skipped OAuth. Fail closed
|
||||
// (connected:false) but report a verification error
|
||||
// rather than fabricating an "OAuth incomplete" reason
|
||||
// that blames the user and can drive the agent into
|
||||
// reconnect loops (#4062, coderabbit review).
|
||||
tracing::warn!(toolkit = %toolkit, error = %e, "[composio] connect.execute: liveness check failed");
|
||||
Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"toolkit": toolkit,
|
||||
"connected": false,
|
||||
"reason": "Approved, but the connection state could not be verified right now (a temporary problem reaching Composio). Please try connecting again in a moment.",
|
||||
}))?))
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::openhuman::approval::GateOutcome::Deny { reason } => {
|
||||
tracing::info!(toolkit = %toolkit, reason = %reason, "[composio] connect.execute: declined");
|
||||
Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"toolkit": toolkit,
|
||||
"connected": false,
|
||||
"declined": true,
|
||||
"reason": reason,
|
||||
}))?))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── composio_list_tools ─────────────────────────────────────────────
|
||||
|
||||
pub struct ComposioListToolsTool {
|
||||
@@ -1255,6 +1526,9 @@ pub fn all_composio_agent_tools(config: &crate::openhuman::config::Config) -> Ve
|
||||
Box::new(ComposioListToolkitsTool::new(config_arc.clone())),
|
||||
Box::new(ComposioListConnectionsTool::new(config_arc.clone())),
|
||||
Box::new(ComposioAuthorizeTool::new(config_arc.clone())),
|
||||
// Inline-in-chat OAuth connect card (#3993). Raises an approval card
|
||||
// with a Connect button instead of handing the agent a raw URL.
|
||||
Box::new(ComposioConnectTool::new(config_arc.clone())),
|
||||
Box::new(ComposioListToolsTool::new(config_arc.clone())),
|
||||
Box::new(ComposioExecuteTool::new(config_arc)),
|
||||
// Pref-elevation is intentionally NOT an agent-callable tool;
|
||||
|
||||
@@ -125,6 +125,7 @@ fn all_composio_tools_are_in_skill_category() {
|
||||
Box::new(ComposioListToolkitsTool::new(config.clone())),
|
||||
Box::new(ComposioListConnectionsTool::new(config.clone())),
|
||||
Box::new(ComposioAuthorizeTool::new(config.clone())),
|
||||
Box::new(ComposioConnectTool::new(config.clone())),
|
||||
Box::new(ComposioListToolsTool::new(config.clone())),
|
||||
Box::new(ComposioExecuteTool::new(config)),
|
||||
];
|
||||
@@ -144,6 +145,7 @@ fn all_composio_tools_are_in_skill_category() {
|
||||
assert!(names.contains(&"composio_list_toolkits"));
|
||||
assert!(names.contains(&"composio_list_connections"));
|
||||
assert!(names.contains(&"composio_authorize"));
|
||||
assert!(names.contains(&"composio_connect"));
|
||||
assert!(names.contains(&"composio_list_tools"));
|
||||
assert!(names.contains(&"composio_execute"));
|
||||
}
|
||||
@@ -217,6 +219,119 @@ async fn authorize_tool_execute_rejects_whitespace_toolkit() {
|
||||
assert!(result.is_error);
|
||||
}
|
||||
|
||||
// ── composio_connect (inline approval card, #3993) ──────────────────
|
||||
|
||||
fn tool_result_text(result: &crate::openhuman::tools::traits::ToolResult) -> String {
|
||||
result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
crate::openhuman::tools::traits::ToolContent::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_tool_metadata_requires_toolkit_and_is_not_auto_gated() {
|
||||
let t = ComposioConnectTool::new(fake_config_arc());
|
||||
assert_eq!(t.name(), "composio_connect");
|
||||
// Gating is done manually inside execute (so it can skip the card when
|
||||
// already connected) — the engine must NOT auto-gate it.
|
||||
assert!(!t.external_effect());
|
||||
let schema = t.parameters_schema();
|
||||
let required: Vec<String> = schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(required, vec!["toolkit"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_tool_execute_rejects_missing_toolkit() {
|
||||
let t = ComposioConnectTool::new(fake_config_arc());
|
||||
let result = t.execute(serde_json::json!({})).await.unwrap();
|
||||
assert!(result.is_error);
|
||||
assert!(tool_result_text(&result).contains("'toolkit' is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_tool_refuses_without_interactive_chat_context() {
|
||||
// No `APPROVAL_CHAT_CONTEXT` in scope → background/cron turn. There is no
|
||||
// surface for the Connect card, so the tool must fail closed (it returns
|
||||
// before touching the network).
|
||||
let t = ComposioConnectTool::new(fake_config_arc());
|
||||
let result = t
|
||||
.execute(serde_json::json!({ "toolkit": "gmail" }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_error);
|
||||
assert!(tool_result_text(&result).contains("[policy-denied]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalize_toolkit_slug_maps_known_aliases_and_passes_through() {
|
||||
// Mirrors the FE `canonicalizeComposioToolkitSlug` map (#3993).
|
||||
assert_eq!(
|
||||
super::canonicalize_toolkit_slug("google_drive"),
|
||||
"googledrive"
|
||||
);
|
||||
assert_eq!(
|
||||
super::canonicalize_toolkit_slug("Google_Calendar"),
|
||||
"googlecalendar"
|
||||
);
|
||||
assert_eq!(
|
||||
super::canonicalize_toolkit_slug("google_sheets"),
|
||||
"googlesheets"
|
||||
);
|
||||
assert_eq!(super::canonicalize_toolkit_slug("feishu"), "larksuite");
|
||||
assert_eq!(super::canonicalize_toolkit_slug("lark"), "larksuite");
|
||||
// Unknown slugs are trimmed + lowercased, not rewritten.
|
||||
assert_eq!(super::canonicalize_toolkit_slug(" Notion "), "notion");
|
||||
assert_eq!(super::canonicalize_toolkit_slug("gmail"), "gmail");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_tool_validates_before_gating_in_chat_context() {
|
||||
use crate::openhuman::approval::{ApprovalChatContext, APPROVAL_CHAT_CONTEXT};
|
||||
// With a chat context the interactive guard passes; with no composio
|
||||
// credentials the client factory errors — so execute canonicalizes the
|
||||
// slug, reloads config, checks connected state, and returns a clean error
|
||||
// at the client-resolution step, exercising the pre-gate body with no
|
||||
// network (#3993).
|
||||
let tool = ComposioConnectTool::new(fake_config_arc());
|
||||
let ctx = ApprovalChatContext {
|
||||
thread_id: "t-test".into(),
|
||||
client_id: "c-test".into(),
|
||||
};
|
||||
let result = APPROVAL_CHAT_CONTEXT
|
||||
.scope(
|
||||
ctx,
|
||||
tool.execute(serde_json::json!({ "toolkit": "google_drive" })),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_error);
|
||||
// Backend mode without creds → "Composio is unavailable" (not the
|
||||
// non-interactive policy refusal, which the chat context bypassed).
|
||||
let txt = tool_result_text(&result).to_lowercase();
|
||||
assert!(txt.contains("composio"), "{txt}");
|
||||
assert!(!txt.contains("[policy-denied]"), "{txt}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_is_active_errs_without_a_client() {
|
||||
// Liveness re-check (#3993): with no composio client (no creds) we cannot
|
||||
// confirm a connection. This must surface as `Err` (state unverifiable) —
|
||||
// NOT `Ok(false)` — so the caller fails closed without fabricating an
|
||||
// "OAuth incomplete" reason that blames the user (#4062, coderabbit).
|
||||
let cfg = crate::openhuman::config::Config::default();
|
||||
assert!(super::connection_is_active(&cfg, "gmail").await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_tools_tool_metadata_accepts_optional_toolkits_filter() {
|
||||
let t = ComposioListToolsTool::new(fake_config_arc());
|
||||
@@ -302,10 +417,10 @@ fn agent_tools_register_when_backend_signed_in() {
|
||||
let tools = all_composio_agent_tools(&config);
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
5,
|
||||
"backend session present → all 5 generic composio agent tools should register \
|
||||
(list_toolkits, list_connections, authorize, list_tools, execute). Scope \
|
||||
elevation is intentionally NOT exposed as an agent tool — the user must \
|
||||
6,
|
||||
"backend session present → all 6 generic composio agent tools should register \
|
||||
(list_toolkits, list_connections, authorize, connect, list_tools, execute). \
|
||||
Scope elevation is intentionally NOT exposed as an agent tool — the user must \
|
||||
flip scopes themselves in the Connections UI."
|
||||
);
|
||||
}
|
||||
@@ -329,9 +444,9 @@ fn agent_tools_register_when_direct_mode_with_stored_key_and_no_backend_session(
|
||||
let tools = all_composio_agent_tools(&config);
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
5,
|
||||
6,
|
||||
"direct mode with stored API key (no backend session) must still register \
|
||||
all 5 generic composio agent tools — the pre-Option-C bug returned 0 here"
|
||||
all 6 generic composio agent tools — the pre-Option-C bug returned 0 here"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ You help the user find and install skills from three registries:
|
||||
1. When the user asks to find a skill, search across registries.
|
||||
2. Present results clearly: name, description, source, install count.
|
||||
3. Ask the user which skill(s) to install if multiple match.
|
||||
4. Install the selected skill and confirm it was added.
|
||||
5. If installation fails, explain the error and suggest alternatives.
|
||||
4. Install the selected skill and confirm it was added. Installing raises an **inline approval card** the user accepts in the chat — you do not navigate away or hand out manual setup steps.
|
||||
5. If the user **declines** the approval card, or installation fails, acknowledge it plainly and suggest alternatives. Do **not** silently retry the same install — one declined/failed attempt is enough.
|
||||
|
||||
## Important rules
|
||||
|
||||
|
||||
@@ -167,6 +167,16 @@ impl Tool for SkillRegistryInstallTool {
|
||||
PermissionLevel::Write
|
||||
}
|
||||
|
||||
/// Installing a skill mutates the user's local skill set and fetches a
|
||||
/// remote `SKILL.md`, so it routes through the process-global
|
||||
/// `ApprovalGate` (#3993). On an interactive chat turn the user sees an
|
||||
/// inline approval card and approves before anything is written; on
|
||||
/// background/cron turns (no `APPROVAL_CHAT_CONTEXT`) the gate is bypassed,
|
||||
/// matching every other external-effect tool.
|
||||
fn external_effect(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let entry_id = args
|
||||
.get("entry_id")
|
||||
@@ -286,3 +296,28 @@ impl Tool for SkillRegistryUninstallTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn install_tool_is_external_effect_so_it_routes_through_approval_gate() {
|
||||
let tool = SkillRegistryInstallTool::new(Arc::new(Config::default()));
|
||||
assert_eq!(tool.name(), "skill_registry_install");
|
||||
// #3993: installs must raise an inline approval card before writing.
|
||||
assert!(
|
||||
tool.external_effect(),
|
||||
"skill_registry_install must declare external_effect so the harness gates it"
|
||||
);
|
||||
assert!(matches!(tool.permission_level(), PermissionLevel::Write));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_skill_tools_are_not_gated() {
|
||||
// Browse/search/sources stay ungated — they only read the catalog.
|
||||
assert!(!SkillRegistryBrowseTool.external_effect());
|
||||
assert!(!SkillRegistrySearchTool.external_effect());
|
||||
assert!(!SkillRegistrySourcesTool.external_effect());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,8 @@ async fn round18_composio_ops_and_agent_tools_cover_backend_errors_and_metadata(
|
||||
.contains("not in the curated whitelist"));
|
||||
|
||||
let registered_tools = all_composio_agent_tools(&harness.config);
|
||||
assert_eq!(registered_tools.len(), 5);
|
||||
// 6 generic composio agent tools incl. the inline-connect card tool (#3993).
|
||||
assert_eq!(registered_tools.len(), 6);
|
||||
|
||||
*state.scenario.lock().expect("scenario") = Scenario::ToolkitsFail;
|
||||
let toolkits_error = composio_list_toolkits(&harness.config)
|
||||
|
||||
@@ -1119,6 +1119,7 @@ async fn composio_agent_tools_cover_metadata_missing_params_and_scope_helpers()
|
||||
"composio_list_toolkits",
|
||||
"composio_list_connections",
|
||||
"composio_authorize",
|
||||
"composio_connect",
|
||||
"composio_list_tools",
|
||||
"composio_execute",
|
||||
]
|
||||
|
||||
@@ -781,6 +781,7 @@ async fn composio_agent_tools_cover_backend_discovery_markdown_and_execution_pat
|
||||
"composio_list_toolkits",
|
||||
"composio_list_connections",
|
||||
"composio_authorize",
|
||||
"composio_connect",
|
||||
"composio_list_tools",
|
||||
"composio_execute",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user