fix(intelligence): deduplicate and label connections in integration source picker (#3361)

This commit is contained in:
YellowSnnowmann
2026-06-04 19:12:23 +05:30
committed by GitHub
parent 122196b718
commit 505b04783b
16 changed files with 375 additions and 13 deletions
@@ -0,0 +1,279 @@
/**
* Tests for the AddMemorySourceDialog — focused on the Composio connection
* picker: deduplication, readable labels, and no raw connection IDs in the
* rendered dropdown (issue #3356).
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { listConnections } from '../../lib/composio/composioApi';
import { renderWithProviders } from '../../test/test-utils';
import { AddMemorySourceDialog, deduplicateConnections } from './AddMemorySourceDialog';
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
vi.mock('../../lib/composio/composioApi', () => ({ listConnections: vi.fn() }));
vi.mock('../../services/memorySourcesService', () => ({
addMemorySource: vi.fn(),
SOURCE_KIND_ICONS: {
folder: '📁',
composio: '🔗',
github_repo: '🐙',
rss_feed: '📡',
web_page: '🌐',
twitter_query: '🐦',
},
SOURCE_KIND_LABEL_KEYS: {
folder: 'memorySources.kind.folder',
composio: 'memorySources.kind.composio',
github_repo: 'memorySources.kind.github_repo',
rss_feed: 'memorySources.kind.rss_feed',
web_page: 'memorySources.kind.web_page',
twitter_query: 'memorySources.kind.twitter_query',
},
}));
const mockListConnections = listConnections as ReturnType<typeof vi.fn>;
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function renderDialog() {
const onClose = vi.fn();
const onAdded = vi.fn();
renderWithProviders(<AddMemorySourceDialog open onClose={onClose} onAdded={onAdded} />);
return { onClose, onAdded };
}
async function openComposioStep() {
renderDialog();
// The i18n context renders the real English string from en.ts
const integrationBtn = screen.getByText('Integration');
fireEvent.click(integrationBtn);
// Wait for async connection fetch
await waitFor(() => expect(mockListConnections).toHaveBeenCalledTimes(1));
}
// ---------------------------------------------------------------------------
// Unit tests: deduplicateConnections helper
// ---------------------------------------------------------------------------
describe('deduplicateConnections', () => {
it('returns an empty array for empty input', () => {
expect(deduplicateConnections([], 'Account')).toEqual([]);
});
it('uses accountEmail as the identity label', () => {
const conn = {
id: 'conn-1',
toolkit: 'Gmail',
status: 'ACTIVE',
accountEmail: 'user@example.com',
};
const result = deduplicateConnections([conn], 'Account');
expect(result).toHaveLength(1);
expect(result[0].label).toBe('Gmail · user@example.com');
expect(result[0].conn.id).toBe('conn-1');
});
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');
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');
expect(result[0].label).toBe('GitHub · octocat');
});
it('uses numbered Account 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');
});
it('numbers multiple no-identity connections per toolkit', () => {
const conns = [
{ id: 'conn-a', toolkit: 'Notion', status: 'ACTIVE' },
{ id: 'conn-b', toolkit: 'Notion', status: 'ACTIVE' },
];
const result = deduplicateConnections(conns, 'Account');
expect(result).toHaveLength(2);
expect(result[0].label).toBe('Notion · Account 1');
expect(result[1].label).toBe('Notion · Account 2');
});
it('deduplicates connections with the same toolkit and identity', () => {
const conns = [
{ 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');
expect(result).toHaveLength(1);
expect(result[0].conn.id).toBe('conn-1');
expect(result[0].label).toBe('Gmail · a@example.com');
});
it('keeps connections with the same toolkit but different identities', () => {
const conns = [
{ 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');
expect(result).toHaveLength(2);
});
it('does not expose raw connection IDs in any label', () => {
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');
}
});
it('numbers no-identity connections per toolkit independently', () => {
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');
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');
});
it('prefers ACTIVE over EXPIRED when deduplicating same toolkit+identity', () => {
// Backend returns EXPIRED first — the ACTIVE one should win
const conns = [
{ 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');
expect(result).toHaveLength(1);
expect(result[0].conn.id).toBe('conn-active');
});
it('deduplicates identity-less connections with the same conn.id', () => {
// Same connection returned twice with no identity — must not produce duplicate React keys
const conns = [
{ id: 'conn-same', toolkit: 'Notion', status: 'ACTIVE' },
{ id: 'conn-same', toolkit: 'Notion', status: 'ACTIVE' },
];
const result = deduplicateConnections(conns, 'Account');
expect(result).toHaveLength(1);
expect(result[0].conn.id).toBe('conn-same');
});
it('sorts CONNECTED equal to ACTIVE above PENDING and EXPIRED', () => {
const conns = [
{ id: 'exp', toolkit: 'Linear', status: 'EXPIRED', accountEmail: 'a@b.com' },
{ 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');
expect(result).toHaveLength(1);
// CONNECTED ranks same as ACTIVE — must win over EXPIRED and PENDING
expect(result[0].conn.id).toBe('connected');
});
});
// ---------------------------------------------------------------------------
// Component tests: ComposioPicker inside the dialog
// ---------------------------------------------------------------------------
describe('AddMemorySourceDialog — Composio picker', () => {
beforeEach(() => {
mockListConnections.mockReset();
});
it('shows loading state while fetching connections', async () => {
// Never resolves during this test
mockListConnections.mockReturnValue(new Promise(() => {}));
renderDialog();
fireEvent.click(screen.getByText('Integration'));
await waitFor(() => expect(screen.queryByText('Loading connections…')).toBeTruthy());
});
it('shows no-connections message when list is empty', async () => {
mockListConnections.mockResolvedValue({ connections: [] });
await openComposioStep();
await waitFor(() =>
expect(
screen.queryByText('No active Composio connections found. Connect an integration first.')
).toBeTruthy()
);
});
it('renders readable labels — toolkit · identity — not raw IDs', async () => {
mockListConnections.mockResolvedValue({
connections: [
{ id: 'raw-id-xyz', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'user@gmail.com' },
],
});
await openComposioStep();
await waitFor(() => expect(screen.queryByText('Gmail · user@gmail.com')).toBeTruthy());
expect(screen.queryByText('raw-id-xyz')).toBeNull();
});
it('deduplicates same toolkit+identity connections in the dropdown', async () => {
mockListConnections.mockResolvedValue({
connections: [
{ id: 'conn-1', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'x@example.com' },
{ id: 'conn-2', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'x@example.com' },
],
});
await openComposioStep();
await waitFor(() => {
const options = screen.getAllByRole('option');
const gmailOptions = options.filter(o => o.textContent?.includes('Gmail · x@example.com'));
expect(gmailOptions).toHaveLength(1);
});
});
it('shows numbered Account labels for connections without identity fields', async () => {
mockListConnections.mockResolvedValue({
connections: [
{ id: 'conn-a', toolkit: 'Notion', status: 'ACTIVE' },
{ id: 'conn-b', toolkit: 'Notion', status: 'ACTIVE' },
],
});
await openComposioStep();
await waitFor(() => {
expect(screen.queryByText('Notion · Account 1')).toBeTruthy();
expect(screen.queryByText('Notion · Account 2')).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 () => {
mockListConnections.mockResolvedValue({
connections: [
{ id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'my-workspace' },
],
});
await openComposioStep();
await waitFor(() => expect(screen.queryByText('Slack · my-workspace')).toBeTruthy());
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: 'conn-1' } });
// The label field should be auto-filled
await waitFor(() => {
const labelInput = screen.getByPlaceholderText('My research notes');
expect((labelInput as HTMLInputElement).value).toBe('Slack · my-workspace');
});
});
});
@@ -8,7 +8,7 @@
* presents them as a dropdown — the user picks an existing OAuth
* connection rather than typing toolkit + connection_id.
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { listConnections } from '../../lib/composio/composioApi';
import type { ComposioConnection } from '../../lib/composio/types';
@@ -487,6 +487,73 @@ function KindFields(props: KindFieldsProps) {
}
}
/** Active-first status rank — lower is better. */
const STATUS_RANK: Record<string, number> = {
ACTIVE: 0,
CONNECTED: 0,
PENDING: 1,
INITIATED: 1,
INITIALIZING: 1,
EXPIRED: 2,
FAILED: 3,
ERROR: 3,
};
function statusRank(conn: ComposioConnection): number {
return STATUS_RANK[conn.status.toUpperCase()] ?? 2;
}
/**
* Deduplicates and labels connections for display in the picker.
*
* - Sorts by status rank first (ACTIVE/CONNECTED before EXPIRED/FAILED) so
* that when two connections share the same toolkit + identity, the healthier
* one wins rather than the first-returned one.
* - 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.
*/
export function deduplicateConnections(
connections: ComposioConnection[],
accountLabel: string
): 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) {
// Always dedup by raw connection id to guard against identity-less dupes.
if (seen.has(conn.id)) {
console.debug(
`[ui-flow][composio-picker] dropping duplicate connection toolkit=${conn.toolkit} id=${conn.id}`
);
continue;
}
seen.add(conn.id);
const identity = conn.accountEmail ?? conn.workspace ?? conn.username;
if (identity) {
const key = `${conn.toolkit}:${identity}`;
if (seen.has(key)) {
console.debug(
`[ui-flow][composio-picker] dropping duplicate connection toolkit=${conn.toolkit} id=${conn.id}`
);
continue;
}
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}` });
}
}
return result;
}
function ComposioPicker({
connections,
loadingConnections,
@@ -494,6 +561,12 @@ function ComposioPicker({
setConnection,
}: KindFieldsProps) {
const { t } = useT();
// useMemo must be declared before any early returns (Rules of Hooks).
const accountLabel = t('memorySources.connectionAccount');
const dedupedConnections = useMemo(
() => deduplicateConnections(connections, accountLabel),
[connections, accountLabel]
);
if (loadingConnections) {
return (
@@ -519,10 +592,9 @@ function ComposioPicker({
<select
value={connectionId}
onChange={e => {
const conn = connections.find(c => c.id === e.target.value);
if (conn) {
const identity = conn.accountEmail ?? conn.workspace ?? conn.username ?? conn.id;
setConnection(conn.id, conn.toolkit, `${conn.toolkit} · ${identity}`);
const entry = dedupedConnections.find(({ conn }) => conn.id === e.target.value);
if (entry) {
setConnection(entry.conn.id, entry.conn.toolkit, entry.label);
}
}}
className="mt-1 block w-full rounded-md border border-stone-300 bg-white px-3 py-2
@@ -530,14 +602,11 @@ function ComposioPicker({
focus:ring-1 focus:ring-primary-400 dark:border-neutral-600
dark:bg-neutral-800 dark:text-neutral-100 dark:focus:border-primary-500">
<option value="">{t('memorySources.selectConnection')}</option>
{connections.map(conn => {
const identity = conn.accountEmail ?? conn.workspace ?? conn.username ?? conn.id;
return (
<option key={conn.id} value={conn.id}>
{conn.toolkit} · {identity}
</option>
);
})}
{dedupedConnections.map(({ conn, label }) => (
<option key={conn.id} value={conn.id}>
{label}
</option>
))}
</select>
</label>
);
+1
View File
@@ -1929,6 +1929,7 @@ const messages: TranslationMap = {
'لا توجد مصادر عرفية بعد أضف ملفاً، ×1xx repo، ×x0x مكعب، أو صفحة على الشبكة للبدء.',
'memorySources.loadingConnections': 'علاقات الحب...',
'memorySources.noConnections': 'لم يتم العثور على أي وصلات اكسكساكسية نشطة. إجمع التكامل أولاً',
'memorySources.connectionAccount': 'الحساب',
'memorySources.pickConnection': 'اختر اتصال',
'memorySources.selectConnection': '- اختيار اتصال -',
'memorySources.composioListFailed': 'فشل في تحميل الأتصالات Xqx0x.',
+1
View File
@@ -1968,6 +1968,7 @@ const messages: TranslationMap = {
'কোনো স্বনির্ধারিত উৎস পাওয়া যায়নি। একটি ফোল্ডার যোগ করুন, xqx1x rex, xqxqx pxkx ফিড, অথবা ওয়েব পেজ আরম্ভ করুন।',
'memorySources.loadingConnections': 'সংযোগ লোড করা হচ্ছে...',
'memorySources.noConnections': 'কোনো সক্রিয় xqxqx সংযোগ পাওয়া যায়নি। একটা সম্পর্ক প্রথমে।',
'memorySources.connectionAccount': 'অ্যাকাউন্ট',
'memorySources.pickConnection': 'একটি সংযোগ নির্বাচন করুন',
'memorySources.selectConnection': '- একটি সংযোগ নির্বাচন করুন -',
'memorySources.composioListFailed': 'Xqxqx সংযোগ লোড করতে ব্যর্থ।',
+1
View File
@@ -2019,6 +2019,7 @@ 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.composioListFailed': 'Fehler beim Laden der Composio-Verbindungen.',
+1
View File
@@ -2298,6 +2298,7 @@ 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.composioListFailed': 'Failed to load Composio connections.',
+1
View File
@@ -2011,6 +2011,7 @@ 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.composioListFailed': 'Error al cargar las conexiones Composio.',
+1
View File
@@ -2017,6 +2017,7 @@ 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.composioListFailed': 'Échec du chargement des connexions Composio.',
+1
View File
@@ -1969,6 +1969,7 @@ const messages: TranslationMap = {
'अभी तक कोई कस्टम स्रोत नहीं है। एक फ़ोल्डर जोड़ें, GitHub रेपो, RSS फीड, या वेब पेज शुरू करने के लिए।',
'memorySources.loadingConnections': 'कनेक्शन लोड हो रहा है...',
'memorySources.noConnections': 'कोई सक्रिय Composio कनेक्शन नहीं मिला। पहले एकीकरण कनेक्ट करें।',
'memorySources.connectionAccount': 'खाता',
'memorySources.pickConnection': 'कनेक्शन चुनें',
'memorySources.selectConnection': '- एक कनेक्शन चुनें -',
'memorySources.composioListFailed': 'Composio कनेक्शन लोड करने में विफल रहा।',
+1
View File
@@ -1972,6 +1972,7 @@ 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.composioListFailed': 'Gagal memuat koneksi Composio.',
+1
View File
@@ -2001,6 +2001,7 @@ 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.composioListFailed': 'Impossibile caricare le connessioni Composio.',
+1
View File
@@ -1946,6 +1946,7 @@ const messages: TranslationMap = {
'아직 사용자 지정 소스가 없습니다. 폴더, GitHub 리포지토리, RSS 피드 또는 웹 페이지를 추가해 시작하세요.',
'memorySources.loadingConnections': '연결을 불러오는 중…',
'memorySources.noConnections': '활성 Composio 연결을 찾을 수 없습니다. 먼저 통합을 연결하세요.',
'memorySources.connectionAccount': '계정',
'memorySources.pickConnection': '연결 선택',
'memorySources.selectConnection': '— 연결 선택 —',
'memorySources.composioListFailed': 'Composio 연결을 불러오지 못했습니다.',
+1
View File
@@ -1990,6 +1990,7 @@ 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.composioListFailed': 'Nie udało się wczytać połączeń Composio.',
+1
View File
@@ -2009,6 +2009,7 @@ 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.composioListFailed': 'Falha ao carregar as conexões Composio.',
+1
View File
@@ -1982,6 +1982,7 @@ const messages: TranslationMap = {
'memorySources.loadingConnections': 'Загрузка подключений…',
'memorySources.noConnections':
'Активных соединений Composio не ​​найдено. Сначала подключите интеграцию.',
'memorySources.connectionAccount': 'Аккаунт',
'memorySources.pickConnection': 'Выберите соединение',
'memorySources.selectConnection': '— Выберите соединение —',
'memorySources.composioListFailed': 'Не удалось загрузить соединения Composio.',
+1
View File
@@ -1868,6 +1868,7 @@ const messages: TranslationMap = {
'暂无自定义来源。添加文件夹、GitHub 仓库、RSS 订阅或网页即可开始。',
'memorySources.loadingConnections': '正在加载连接…',
'memorySources.noConnections': '未找到活跃的 Composio 连接。请先连接一个集成。',
'memorySources.connectionAccount': '账户',
'memorySources.pickConnection': '选择连接',
'memorySources.selectConnection': '— 选择连接 —',
'memorySources.composioListFailed': '加载 Composio 连接失败。',