fix(composio): show connection ID in picker when no identity cached (#3405)

This commit is contained in:
YellowSnnowmann
2026-06-05 10:10:29 -04:00
committed by GitHub
parent 6056ed9310
commit 77cfea2b61
18 changed files with 56 additions and 71 deletions
@@ -79,7 +79,7 @@ async function openListbox() {
describe('deduplicateConnections', () => {
it('returns an empty array for empty input', () => {
expect(deduplicateConnections([], 'Account')).toEqual([]);
expect(deduplicateConnections([])).toEqual([]);
});
it('uses accountEmail as the identity label', () => {
@@ -89,7 +89,7 @@ describe('deduplicateConnections', () => {
status: 'ACTIVE',
accountEmail: 'user@example.com',
};
const result = deduplicateConnections([conn], 'Account');
const result = deduplicateConnections([conn]);
expect(result).toHaveLength(1);
expect(result[0].label).toBe('Gmail · user@example.com');
expect(result[0].conn.id).toBe('conn-1');
@@ -97,31 +97,31 @@ describe('deduplicateConnections', () => {
it('falls back to workspace when accountEmail is absent', () => {
const conn = { id: 'conn-2', toolkit: 'Slack', status: 'ACTIVE', workspace: 'my-workspace' };
const result = deduplicateConnections([conn], 'Account');
const result = deduplicateConnections([conn]);
expect(result[0].label).toBe('Slack · my-workspace');
});
it('falls back to username when neither email nor workspace is present', () => {
const conn = { id: 'conn-3', toolkit: 'GitHub', status: 'ACTIVE', username: 'octocat' };
const result = deduplicateConnections([conn], 'Account');
const result = deduplicateConnections([conn]);
expect(result[0].label).toBe('GitHub · octocat');
});
it('uses numbered Account label when no identity field is available', () => {
it('uses connection ID as label when no identity field is available', () => {
const conn = { id: 'conn-x', toolkit: 'Notion', status: 'ACTIVE' };
const result = deduplicateConnections([conn], 'Account');
expect(result[0].label).toBe('Notion · Account 1');
const result = deduplicateConnections([conn]);
expect(result[0].label).toBe('Notion · conn-x');
});
it('numbers multiple no-identity connections per toolkit', () => {
it('shows each connection ID for multiple no-identity connections', () => {
const conns = [
{ id: 'conn-a', toolkit: 'Notion', status: 'ACTIVE' },
{ id: 'conn-b', toolkit: 'Notion', status: 'ACTIVE' },
];
const result = deduplicateConnections(conns, 'Account');
const result = deduplicateConnections(conns);
expect(result).toHaveLength(2);
expect(result[0].label).toBe('Notion · Account 1');
expect(result[1].label).toBe('Notion · Account 2');
expect(result[0].label).toBe('Notion · conn-a');
expect(result[1].label).toBe('Notion · conn-b');
});
it('deduplicates connections with the same toolkit and identity', () => {
@@ -129,7 +129,7 @@ describe('deduplicateConnections', () => {
{ id: 'conn-1', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@example.com' },
{ id: 'conn-2', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@example.com' },
];
const result = deduplicateConnections(conns, 'Account');
const result = deduplicateConnections(conns);
expect(result).toHaveLength(1);
expect(result[0].conn.id).toBe('conn-1');
expect(result[0].label).toBe('Gmail · a@example.com');
@@ -140,32 +140,31 @@ describe('deduplicateConnections', () => {
{ id: 'conn-1', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@example.com' },
{ id: 'conn-2', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'b@example.com' },
];
const result = deduplicateConnections(conns, 'Account');
const result = deduplicateConnections(conns);
expect(result).toHaveLength(2);
});
it('does not expose raw connection IDs in any label', () => {
it('uses the connection ID in the label when no identity is available', () => {
const conns = [
{ id: 'raw-uuid-abc123', toolkit: 'Linear', status: 'ACTIVE' },
{ id: 'raw-uuid-def456', toolkit: 'Linear', status: 'ACTIVE' },
];
const result = deduplicateConnections(conns, 'Account');
for (const { label } of result) {
expect(label).not.toContain('raw-uuid');
}
const result = deduplicateConnections(conns);
expect(result[0].label).toBe('Linear · raw-uuid-abc123');
expect(result[1].label).toBe('Linear · raw-uuid-def456');
});
it('numbers no-identity connections per toolkit independently', () => {
it('shows connection IDs for no-identity connections across toolkits', () => {
const conns = [
{ id: 'n-1', toolkit: 'Notion', status: 'ACTIVE' },
{ id: 's-1', toolkit: 'Slack', status: 'ACTIVE' },
{ id: 'n-2', toolkit: 'Notion', status: 'ACTIVE' },
];
const result = deduplicateConnections(conns, 'Account');
const result = deduplicateConnections(conns);
expect(result).toHaveLength(3);
expect(result.find(r => r.conn.id === 'n-1')?.label).toBe('Notion · Account 1');
expect(result.find(r => r.conn.id === 'n-2')?.label).toBe('Notion · Account 2');
expect(result.find(r => r.conn.id === 's-1')?.label).toBe('Slack · Account 1');
expect(result.find(r => r.conn.id === 'n-1')?.label).toBe('Notion · n-1');
expect(result.find(r => r.conn.id === 'n-2')?.label).toBe('Notion · n-2');
expect(result.find(r => r.conn.id === 's-1')?.label).toBe('Slack · s-1');
});
it('prefers ACTIVE over EXPIRED when deduplicating same toolkit+identity', () => {
@@ -174,7 +173,7 @@ describe('deduplicateConnections', () => {
{ id: 'conn-expired', toolkit: 'Gmail', status: 'EXPIRED', accountEmail: 'x@example.com' },
{ id: 'conn-active', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'x@example.com' },
];
const result = deduplicateConnections(conns, 'Account');
const result = deduplicateConnections(conns);
expect(result).toHaveLength(1);
expect(result[0].conn.id).toBe('conn-active');
});
@@ -185,7 +184,7 @@ describe('deduplicateConnections', () => {
{ id: 'conn-same', toolkit: 'Notion', status: 'ACTIVE' },
{ id: 'conn-same', toolkit: 'Notion', status: 'ACTIVE' },
];
const result = deduplicateConnections(conns, 'Account');
const result = deduplicateConnections(conns);
expect(result).toHaveLength(1);
expect(result[0].conn.id).toBe('conn-same');
});
@@ -196,7 +195,7 @@ describe('deduplicateConnections', () => {
{ id: 'pending', toolkit: 'Linear', status: 'PENDING', accountEmail: 'a@b.com' },
{ id: 'connected', toolkit: 'Linear', status: 'CONNECTED', accountEmail: 'a@b.com' },
];
const result = deduplicateConnections(conns, 'Account');
const result = deduplicateConnections(conns);
expect(result).toHaveLength(1);
// CONNECTED ranks same as ACTIVE — must win over EXPIRED and PENDING
expect(result[0].conn.id).toBe('connected');
@@ -260,7 +259,7 @@ describe('AddMemorySourceDialog — Composio picker', () => {
});
});
it('shows numbered Account labels for connections without identity fields', async () => {
it('shows connection IDs for connections without identity fields', async () => {
mockListConnections.mockResolvedValue({
connections: [
{ id: 'conn-a', toolkit: 'Notion', status: 'ACTIVE' },
@@ -270,12 +269,9 @@ describe('AddMemorySourceDialog — Composio picker', () => {
await openComposioStep();
await openListbox();
await waitFor(() => {
expect(screen.queryByText('Notion · Account 1')).toBeTruthy();
expect(screen.queryByText('Notion · Account 2')).toBeTruthy();
expect(screen.queryByText('Notion · conn-a')).toBeTruthy();
expect(screen.queryByText('Notion · conn-b')).toBeTruthy();
});
// Raw IDs must not appear
expect(screen.queryByText('conn-a')).toBeNull();
expect(screen.queryByText('conn-b')).toBeNull();
});
it('auto-fills the source label when a connection is selected', async () => {
@@ -544,16 +544,14 @@ function statusRank(conn: ComposioConnection): number {
* - Connections sharing the same toolkit + identity (accountEmail / workspace /
* username) OR the same raw connection id are collapsed to the first
* occurrence, preventing both labeled and identity-less duplicates.
* - Connections with no identity field get a numbered "Account N" suffix
* scoped per toolkit, so users can distinguish them without seeing raw IDs.
* - Connections with no identity field fall back to showing the raw connection ID
* so users can unambiguously distinguish accounts.
*/
export function deduplicateConnections(
connections: ComposioConnection[],
accountLabel: string
connections: ComposioConnection[]
): Array<{ conn: ComposioConnection; label: string }> {
const sorted = [...connections].sort((a, b) => statusRank(a) - statusRank(b));
const seen = new Set<string>();
const unidentifiedCount: Record<string, number> = {};
const result: Array<{ conn: ComposioConnection; label: string }> = [];
for (const conn of sorted) {
@@ -574,9 +572,9 @@ export function deduplicateConnections(
seen.add(key);
result.push({ conn, label: `${conn.toolkit} · ${identity}` });
} else {
unidentifiedCount[conn.toolkit] = (unidentifiedCount[conn.toolkit] ?? 0) + 1;
const n = unidentifiedCount[conn.toolkit];
result.push({ conn, label: `${conn.toolkit} · ${accountLabel} ${n}` });
// Fall back to the raw connection ID so the user can unambiguously
// distinguish accounts when no identity data is available.
result.push({ conn, label: `${conn.toolkit} · ${conn.id}` });
}
}
return result;
@@ -612,9 +610,8 @@ function ComposioPicker({
const listboxRef = useRef<HTMLUListElement>(null);
// useMemo must be declared before any early returns (Rules of Hooks).
const accountLabel = t('memorySources.connectionAccount');
const entries = useMemo<PickerEntry[]>(() => {
const deduped = deduplicateConnections(connections, accountLabel).map(({ conn, label }) => ({
const deduped = deduplicateConnections(connections).map(({ conn, label }) => ({
conn,
label,
supported: isToolkitSupported(conn.toolkit, supportedToolkits),
@@ -622,7 +619,7 @@ function ComposioPicker({
// Supported connections first so the actionable ones surface at the top;
// stable within each partition (dedup already ranked by health/status).
return [...deduped.filter(e => e.supported), ...deduped.filter(e => !e.supported)];
}, [connections, accountLabel, supportedToolkits]);
}, [connections, supportedToolkits]);
// Indexes of keyboard-selectable (supported) options — unsupported rows are
// skipped during arrow navigation, mirroring a native <select>'s disabled opts.
-1
View File
@@ -1934,7 +1934,6 @@ const messages: TranslationMap = {
'لا توجد مصادر عرفية بعد أضف ملفاً، ×1xx repo، ×x0x مكعب، أو صفحة على الشبكة للبدء.',
'memorySources.loadingConnections': 'علاقات الحب...',
'memorySources.noConnections': 'لم يتم العثور على أي وصلات اكسكساكسية نشطة. إجمع التكامل أولاً',
'memorySources.connectionAccount': 'الحساب',
'memorySources.pickConnection': 'اختر اتصال',
'memorySources.selectConnection': '- اختيار اتصال -',
'memorySources.comingSoon': 'قريباً',
-1
View File
@@ -1973,7 +1973,6 @@ const messages: TranslationMap = {
'কোনো স্বনির্ধারিত উৎস পাওয়া যায়নি। একটি ফোল্ডার যোগ করুন, xqx1x rex, xqxqx pxkx ফিড, অথবা ওয়েব পেজ আরম্ভ করুন।',
'memorySources.loadingConnections': 'সংযোগ লোড করা হচ্ছে...',
'memorySources.noConnections': 'কোনো সক্রিয় xqxqx সংযোগ পাওয়া যায়নি। একটা সম্পর্ক প্রথমে।',
'memorySources.connectionAccount': 'অ্যাকাউন্ট',
'memorySources.pickConnection': 'একটি সংযোগ নির্বাচন করুন',
'memorySources.selectConnection': '- একটি সংযোগ নির্বাচন করুন -',
'memorySources.comingSoon': 'শীঘ্রই আসছে',
-1
View File
@@ -2024,7 +2024,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Verbindungen werden geladen...',
'memorySources.noConnections':
'Keine aktiven Composio-Verbindungen gefunden. Verbinden Sie zuerst eine Integration.',
'memorySources.connectionAccount': 'Konto',
'memorySources.pickConnection': 'Wählen Sie eine Verbindung',
'memorySources.selectConnection': 'Wählen Sie eine Verbindung aus.',
'memorySources.comingSoon': 'Demnächst',
-1
View File
@@ -2429,7 +2429,6 @@ const en: TranslationMap = {
'memorySources.loadingConnections': 'Loading connections…',
'memorySources.noConnections':
'No active Composio connections found. Connect an integration first.',
'memorySources.connectionAccount': 'Account',
'memorySources.pickConnection': 'Pick a connection',
'memorySources.selectConnection': '— Select a connection —',
'memorySources.comingSoon': 'Coming soon',
-1
View File
@@ -2016,7 +2016,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Cargando conexiones…',
'memorySources.noConnections':
'No se encontraron conexiones activas de Composio. Conecta una integración primero.',
'memorySources.connectionAccount': 'Cuenta',
'memorySources.pickConnection': 'Elige una conexión',
'memorySources.selectConnection': '— Seleccionar una conexión —',
'memorySources.comingSoon': 'Próximamente',
-1
View File
@@ -2022,7 +2022,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Chargement des connexions…',
'memorySources.noConnections':
"Aucune connexion Composio active trouvée. Connectez d'abord une intégration.",
'memorySources.connectionAccount': 'Compte',
'memorySources.pickConnection': 'Choisissez une connexion',
'memorySources.selectConnection': '— Sélectionnez une connexion —',
'memorySources.comingSoon': 'Bientôt disponible',
-1
View File
@@ -1974,7 +1974,6 @@ const messages: TranslationMap = {
'अभी तक कोई कस्टम स्रोत नहीं है। एक फ़ोल्डर जोड़ें, GitHub रेपो, RSS फीड, या वेब पेज शुरू करने के लिए।',
'memorySources.loadingConnections': 'कनेक्शन लोड हो रहा है...',
'memorySources.noConnections': 'कोई सक्रिय Composio कनेक्शन नहीं मिला। पहले एकीकरण कनेक्ट करें।',
'memorySources.connectionAccount': 'खाता',
'memorySources.pickConnection': 'कनेक्शन चुनें',
'memorySources.selectConnection': '- एक कनेक्शन चुनें -',
'memorySources.comingSoon': 'जल्द आ रहा है',
-1
View File
@@ -1977,7 +1977,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Memuat koneksi...',
'memorySources.noConnections':
'Tak ditemukan koneksi Composio yang aktif. Hubungkan integrasi dulu.',
'memorySources.connectionAccount': 'Akun',
'memorySources.pickConnection': 'Pilih koneksi',
'memorySources.selectConnection': '- Pilih koneksi -',
'memorySources.comingSoon': 'Segera hadir',
-1
View File
@@ -2006,7 +2006,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Caricamento delle connessioni…',
'memorySources.noConnections':
"Nessuna connessione Composio attiva trovata. Collega prima un'integrazione.",
'memorySources.connectionAccount': 'Account',
'memorySources.pickConnection': 'Scegli una connessione',
'memorySources.selectConnection': '— Seleziona una connessione —',
'memorySources.comingSoon': 'Prossimamente',
-1
View File
@@ -1951,7 +1951,6 @@ const messages: TranslationMap = {
'아직 사용자 지정 소스가 없습니다. 폴더, GitHub 리포지토리, RSS 피드 또는 웹 페이지를 추가해 시작하세요.',
'memorySources.loadingConnections': '연결을 불러오는 중…',
'memorySources.noConnections': '활성 Composio 연결을 찾을 수 없습니다. 먼저 통합을 연결하세요.',
'memorySources.connectionAccount': '계정',
'memorySources.pickConnection': '연결 선택',
'memorySources.selectConnection': '— 연결 선택 —',
'memorySources.comingSoon': '출시 예정',
-1
View File
@@ -1995,7 +1995,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Wczytywanie połączeń…',
'memorySources.noConnections':
'Nie znaleziono aktywnych połączeń Composio. Najpierw połącz integrację.',
'memorySources.connectionAccount': 'Konto',
'memorySources.pickConnection': 'Wybierz połączenie',
'memorySources.selectConnection': '— Wybierz połączenie —',
'memorySources.comingSoon': 'Wkrótce',
-1
View File
@@ -2014,7 +2014,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Carregando conexões…',
'memorySources.noConnections':
'Nenhuma conexão Composio ativa encontrada. Conecte uma integração primeiro.',
'memorySources.connectionAccount': 'Conta',
'memorySources.pickConnection': 'Escolha uma conexão',
'memorySources.selectConnection': '— Selecionar uma conexão —',
'memorySources.comingSoon': 'Em breve',
-1
View File
@@ -1987,7 +1987,6 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Загрузка подключений…',
'memorySources.noConnections':
'Активных соединений Composio не ​​найдено. Сначала подключите интеграцию.',
'memorySources.connectionAccount': 'Аккаунт',
'memorySources.pickConnection': 'Выберите соединение',
'memorySources.selectConnection': '— Выберите соединение —',
'memorySources.comingSoon': 'Скоро',
-1
View File
@@ -1873,7 +1873,6 @@ const messages: TranslationMap = {
'暂无自定义来源。添加文件夹、GitHub 仓库、RSS 订阅或网页即可开始。',
'memorySources.loadingConnections': '正在加载连接…',
'memorySources.noConnections': '未找到活跃的 Composio 连接。请先连接一个集成。',
'memorySources.connectionAccount': '账户',
'memorySources.pickConnection': '选择连接',
'memorySources.selectConnection': '— 选择连接 —',
'memorySources.comingSoon': '即将推出',
+9 -1
View File
@@ -1539,7 +1539,15 @@ fn enrich_connections_with_identity(
// (normalized_toolkit, normalized_conn_id) → identity
let lookup: HashMap<(String, String), _> = identities
.iter()
.map(|id| ((id.source.clone(), id.identifier.clone()), id))
.map(|id| {
(
(
normalize_connection_identifier(&id.source),
normalize_connection_identifier(&id.identifier),
),
id,
)
})
.collect();
tracing::debug!(
+11 -13
View File
@@ -2270,14 +2270,12 @@ fn composio_direct_500_does_not_demote() {
/// isolated profile storage. The returned guard prevents concurrent tests from
/// rebinding the singleton while the calling test is running — hold it with
/// `let _guard = init_memory_client(tmp.path());`.
fn init_memory_client(workspace: &std::path::Path) -> std::sync::MutexGuard<'static, ()> {
static ENRICH_IDENTITY_TEST_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> =
std::sync::OnceLock::new();
let guard = ENRICH_IDENTITY_TEST_LOCK
.get_or_init(|| std::sync::Mutex::new(()))
async fn init_memory_client(workspace: &std::path::Path) -> tokio::sync::MutexGuard<'static, ()> {
let guard = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ = crate::openhuman::memory::global::init(workspace.to_path_buf());
.await;
crate::openhuman::memory::global::init(workspace.to_path_buf())
.expect("global memory client should initialize for enrichment test");
guard
}
@@ -2298,7 +2296,7 @@ async fn enrich_does_nothing_when_no_cached_identities() {
// profiles, so load_connected_identities returns Vec::new() and the
// connection is returned unchanged.
let tmp = tempfile::tempdir().unwrap();
let _guard = init_memory_client(tmp.path());
let _guard = init_memory_client(tmp.path()).await;
let resp = make_connections_response(&[("c1", "gmail", "ACTIVE")]);
let enriched = enrich_connections_with_identity(resp);
assert_eq!(enriched.connections.len(), 1);
@@ -2313,7 +2311,7 @@ async fn enrich_populates_email_from_cached_profile() {
profile::persist_provider_profile, ProviderUserProfile,
};
let tmp = tempfile::tempdir().unwrap();
let _guard = init_memory_client(tmp.path());
let _guard = init_memory_client(tmp.path()).await;
persist_provider_profile(&ProviderUserProfile {
toolkit: "gmail".to_string(),
@@ -2348,7 +2346,7 @@ async fn enrich_populates_handle_for_github() {
profile::persist_provider_profile, ProviderUserProfile,
};
let tmp = tempfile::tempdir().unwrap();
let _guard = init_memory_client(tmp.path());
let _guard = init_memory_client(tmp.path()).await;
persist_provider_profile(&ProviderUserProfile {
toolkit: "github".to_string(),
@@ -2391,7 +2389,7 @@ async fn enrich_handles_multiple_connections_same_toolkit() {
profile::persist_provider_profile, ProviderUserProfile,
};
let tmp = tempfile::tempdir().unwrap();
let _guard = init_memory_client(tmp.path());
let _guard = init_memory_client(tmp.path()).await;
persist_provider_profile(&ProviderUserProfile {
toolkit: "gmail".to_string(),
@@ -2427,12 +2425,12 @@ async fn enrich_handles_multiple_connections_same_toolkit() {
#[tokio::test]
async fn enrich_leaves_unmatched_connection_unchanged() {
// Connection whose id has no cached profile row is returned with all
// identity fields as None — the UI falls back to "Account N".
// identity fields as None — the UI falls back to "toolkit · connection_id".
use crate::openhuman::memory_sync::composio::providers::{
profile::persist_provider_profile, ProviderUserProfile,
};
let tmp = tempfile::tempdir().unwrap();
let _guard = init_memory_client(tmp.path());
let _guard = init_memory_client(tmp.path()).await;
// Persist a profile for a DIFFERENT connection id.
persist_provider_profile(&ProviderUserProfile {