feat(memory): clear source memory on disconnect (#2528)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YOMXXX
2026-05-24 23:24:35 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent bf2400f0f9
commit 484e31dc1d
36 changed files with 1230 additions and 69 deletions
+84 -29
View File
@@ -46,6 +46,9 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
const [busyKeys, setBusyKeys] = useState<Record<string, boolean>>({});
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState<Record<string, boolean>>(
{}
);
const [error, setError] = useState<string | null>(null);
/** Pending link tokens, keyed by compositeKey (discord:managed_dm). Only present while polling. */
const [linkToken, setLinkToken] = useState<string | null>(null);
@@ -259,15 +262,19 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
const handleDisconnect = useCallback(
(authMode: ChannelAuthMode) => {
const key = `discord:${authMode}`;
void runBusy(`discord:${authMode}`, async () => {
log('disconnecting discord via %s', authMode);
pollAbort.current?.abort();
setLinkToken(null);
await channelConnectionsApi.disconnectChannel('discord', authMode);
await channelConnectionsApi.disconnectChannel('discord', authMode, {
clearMemory: Boolean(clearMemoryOnDisconnect[key]),
});
setClearMemoryOnDisconnect(prev => ({ ...prev, [key]: false }));
dispatch(disconnectChannelConnection({ channel: 'discord', authMode }));
});
},
[dispatch, runBusy]
[clearMemoryOnDisconnect, dispatch, runBusy]
);
const copyToken = useCallback(() => {
@@ -366,38 +373,86 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
{/* Connected state for managed_dm — show only Disconnect */}
{spec.mode === 'managed_dm' && status === 'connected' ? (
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-sage-700 dark:text-sage-300 font-medium">
{t('channels.discord.accountLinked')}
</p>
<button
type="button"
disabled={busy}
onClick={() => handleDisconnect(spec.mode)}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
{t('accounts.disconnect')}
</button>
</div>
) : /* Connect / Disconnect buttons for all other modes and states */
spec.mode !== 'managed_dm' || status !== 'connecting' ? (
<div className="mt-3 flex gap-2">
{status !== 'connected' && (
<>
<label className="mt-3 flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
<input
type="checkbox"
checked={Boolean(clearMemoryOnDisconnect[compositeKey])}
onChange={event =>
setClearMemoryOnDisconnect(prev => ({
...prev,
[compositeKey]: event.currentTarget.checked,
}))
}
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-stone-800 dark:text-neutral-100">
{t('accounts.disconnectClearMemory')}
</span>
<span className="block text-[11px] text-stone-500 dark:text-neutral-400">
{t('accounts.disconnectClearMemoryHint')}
</span>
</span>
</label>
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-sage-700 dark:text-sage-300 font-medium">
{t('channels.discord.accountLinked')}
</p>
<button
type="button"
disabled={busy}
onClick={() => handleConnect(spec)}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
{t('channels.discord.connect')}
onClick={() => handleDisconnect(spec.mode)}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
{t('accounts.disconnect')}
</button>
</div>
</>
) : /* Connect / Disconnect buttons for all other modes and states */
spec.mode !== 'managed_dm' || status !== 'connecting' ? (
<>
{status === 'connected' && (
<label className="mt-3 flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
<input
type="checkbox"
checked={Boolean(clearMemoryOnDisconnect[compositeKey])}
onChange={event =>
setClearMemoryOnDisconnect(prev => ({
...prev,
[compositeKey]: event.currentTarget.checked,
}))
}
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-stone-800 dark:text-neutral-100">
{t('accounts.disconnectClearMemory')}
</span>
<span className="block text-[11px] text-stone-500 dark:text-neutral-400">
{t('accounts.disconnectClearMemoryHint')}
</span>
</span>
</label>
)}
<button
type="button"
disabled={busy || status === 'disconnected'}
onClick={() => handleDisconnect(spec.mode)}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
{t('accounts.disconnect')}
</button>
</div>
<div className="mt-3 flex gap-2">
{status !== 'connected' && (
<button
type="button"
disabled={busy}
onClick={() => handleConnect(spec)}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
{t('channels.discord.connect')}
</button>
)}
<button
type="button"
disabled={busy || status === 'disconnected'}
onClick={() => handleDisconnect(spec.mode)}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
{t('accounts.disconnect')}
</button>
</div>
</>
) : null}
{/* Server + Channel picker — shown after successful bot_token connection */}
+32 -2
View File
@@ -46,6 +46,9 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
const [busyKeys, setBusyKeys] = useState<Record<string, boolean>>({});
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState<Record<string, boolean>>(
{}
);
const [error, setError] = useState<string | null>(null);
const managedDmPollControllers = useRef<Record<string, AbortController>>({});
@@ -323,11 +326,14 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
void runBusy(key, async () => {
log('disconnecting telegram via %s', authMode);
stopManagedDmPolling(`telegram:${authMode}`);
await channelConnectionsApi.disconnectChannel('telegram', authMode);
await channelConnectionsApi.disconnectChannel('telegram', authMode, {
clearMemory: Boolean(clearMemoryOnDisconnect[key]),
});
setClearMemoryOnDisconnect(prev => ({ ...prev, [key]: false }));
dispatch(disconnectChannelConnection({ channel: 'telegram', authMode }));
});
},
[dispatch, runBusy, stopManagedDmPolling]
[clearMemoryOnDisconnect, dispatch, runBusy, stopManagedDmPolling]
);
return (
@@ -397,6 +403,30 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
</div>
)}
{status === 'connected' && (
<label className="mt-3 flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
<input
type="checkbox"
checked={Boolean(clearMemoryOnDisconnect[compositeKey])}
onChange={event =>
setClearMemoryOnDisconnect(prev => ({
...prev,
[compositeKey]: event.currentTarget.checked,
}))
}
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-stone-800 dark:text-neutral-100">
{t('accounts.disconnectClearMemory')}
</span>
<span className="block text-[11px] text-stone-500 dark:text-neutral-400">
{t('accounts.disconnectClearMemoryHint')}
</span>
</span>
</label>
)}
<div className="mt-3 flex gap-2">
<button
type="button"
@@ -1,8 +1,10 @@
import { screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions';
import { renderWithProviders } from '../../../test/test-utils';
import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi';
import { upsertChannelConnection } from '../../../store/channelConnectionsSlice';
import { createTestStore, renderWithProviders } from '../../../test/test-utils';
import DiscordConfig from '../DiscordConfig';
const coreStateMock = vi.hoisted(() => vi.fn(() => ({ snapshot: { sessionToken: 'jwt-abc' } })));
@@ -11,6 +13,27 @@ vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => cor
const discordDef = FALLBACK_DEFINITIONS.find(d => d.id === 'discord')!;
vi.mock('../../../hooks/useOAuthConnectionListener', () => ({
useOAuthConnectionListener: vi.fn(),
}));
vi.mock('../../../services/api/channelConnectionsApi', () => ({
channelConnectionsApi: {
connectChannel: vi.fn(),
disconnectChannel: vi.fn(),
discordLinkStart: vi.fn(),
discordLinkCheck: vi.fn(),
listDefinitions: vi.fn(),
listStatus: vi.fn(),
},
}));
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('../../../utils/tauriCommands/core', () => ({ restartCoreProcess: vi.fn() }));
afterEach(() => {
vi.clearAllMocks();
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'jwt-abc' } });
@@ -40,6 +63,62 @@ describe('DiscordConfig', () => {
expect(connectButtons.length).toBe(3);
});
it('passes clearMemory when disconnecting a connected bot token account', async () => {
const store = createTestStore();
store.dispatch(
upsertChannelConnection({
channel: 'discord',
authMode: 'bot_token',
patch: { status: 'connected', capabilities: ['read', 'write'] },
})
);
vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined);
renderWithProviders(<DiscordConfig definition={discordDef} />, { store });
fireEvent.click(screen.getByLabelText(/also delete memory/i));
const disconnectButton = screen
.getAllByRole('button', { name: 'Disconnect' })
.find(button => !button.hasAttribute('disabled'));
expect(disconnectButton).toBeDefined();
fireEvent.click(disconnectButton!);
await waitFor(() => {
expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith('discord', 'bot_token', {
clearMemory: true,
});
});
});
it('passes clearMemory when disconnecting a connected managed DM account', async () => {
const store = createTestStore();
store.dispatch(
upsertChannelConnection({
channel: 'discord',
authMode: 'managed_dm',
patch: { status: 'connected', capabilities: ['dm'] },
})
);
vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined);
renderWithProviders(<DiscordConfig definition={discordDef} />, { store });
fireEvent.click(screen.getByLabelText(/also delete memory/i));
const disconnectButton = screen
.getAllByRole('button', { name: 'Disconnect' })
.find(button => !button.hasAttribute('disabled'));
expect(disconnectButton).toBeDefined();
fireEvent.click(disconnectButton!);
await waitFor(() => {
expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith(
'discord',
'managed_dm',
{ clearMemory: true }
);
});
});
it('hides managed channel auth modes for local users', () => {
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } });
@@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions';
import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi';
import { renderWithProviders } from '../../../test/test-utils';
import { upsertChannelConnection } from '../../../store/channelConnectionsSlice';
import { createTestStore, renderWithProviders } from '../../../test/test-utils';
import { openUrl } from '../../../utils/openUrl';
import TelegramConfig from '../TelegramConfig';
@@ -69,6 +70,35 @@ describe('TelegramConfig', () => {
});
});
it('passes clearMemory when disconnecting with the memory checkbox selected', async () => {
const store = createTestStore();
store.dispatch(
upsertChannelConnection({
channel: 'telegram',
authMode: 'bot_token',
patch: { status: 'connected', capabilities: ['read', 'write'] },
})
);
vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined);
renderWithProviders(<TelegramConfig definition={telegramDef} />, { store });
fireEvent.click(screen.getByLabelText(/also delete memory/i));
const disconnectButton = screen
.getAllByRole('button', { name: 'Disconnect' })
.find(button => !button.hasAttribute('disabled'));
expect(disconnectButton).toBeDefined();
fireEvent.click(disconnectButton!);
await waitFor(() => {
expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith(
'telegram',
'bot_token',
{ clearMemory: true }
);
});
});
it('starts managed dm flow via core RPC, opens the deep link, and marks connected after polling', async () => {
vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({
status: 'pending_auth',
@@ -223,6 +223,47 @@ describe('<ComposioConnectModal>', () => {
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
});
it('passes clearMemory only when the disconnect memory checkbox is selected', async () => {
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
vi.mocked(composioApi.deleteConnection).mockResolvedValue({
deleted: true,
memory_chunks_deleted: 1,
});
render(
<ComposioConnectModal toolkit={mockToolkit} connection={connection} onClose={() => {}} />
);
fireEvent.click(screen.getByLabelText(/also delete memory/i));
fireEvent.click(screen.getByRole('button', { name: /^Disconnect$/i }));
await waitFor(() => {
expect(composioApi.deleteConnection).toHaveBeenCalledWith('ca_xyz', { clearMemory: true });
});
});
it('resets the clear-memory checkbox after a failed disconnect is dismissed', async () => {
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
vi.mocked(composioApi.deleteConnection).mockRejectedValueOnce(new Error('backend down'));
render(
<ComposioConnectModal toolkit={mockToolkit} connection={connection} onClose={() => {}} />
);
const checkbox = screen.getByLabelText(/also delete memory/i);
fireEvent.click(checkbox);
expect(checkbox).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: /^Disconnect$/i }));
expect(await screen.findByText(/backend down/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
await waitFor(() => {
expect(screen.getByLabelText(/also delete memory/i)).not.toBeChecked();
});
});
it('shows an expired-auth recovery state with a reconnect CTA', () => {
const connection: ComposioConnection = {
id: 'ca_expired',
@@ -194,6 +194,7 @@ export default function ComposioConnectModal({
);
const [error, setError] = useState<string | null>(null);
const [connectUrl, setConnectUrl] = useState<string | null>(null);
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState(false);
// Provider-specific required fields are sourced from the declarative
// registry rather than per-toolkit booleans (#2127). New providers
@@ -508,16 +509,18 @@ export default function ComposioConnectModal({
setPhase('disconnecting');
setError(null);
try {
await deleteConnection(activeConnection.id);
await deleteConnection(activeConnection.id, { clearMemory: clearMemoryOnDisconnect });
setActiveConnection(undefined);
setClearMemoryOnDisconnect(false);
setPhase('idle');
onChanged?.();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setPhase('error');
setError(`${t('composio.connect.disconnectFailed')}: ${msg}`);
setClearMemoryOnDisconnect(false);
}
}, [activeConnection, onChanged, t]);
}, [activeConnection, clearMemoryOnDisconnect, onChanged, t]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
@@ -734,6 +737,22 @@ export default function ComposioConnectModal({
connectionId={activeConnection.id}
/>
)}
<label className="flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<input
type="checkbox"
checked={clearMemoryOnDisconnect}
onChange={event => setClearMemoryOnDisconnect(event.currentTarget.checked)}
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100">
{t('accounts.disconnectClearMemory')}
</span>
<span className="block text-xs text-stone-500 dark:text-neutral-400">
{t('accounts.disconnectClearMemoryHint')}
</span>
</span>
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
@@ -765,6 +784,7 @@ export default function ComposioConnectModal({
<button
type="button"
onClick={() => {
setClearMemoryOnDisconnect(false);
setPhase(
initiallyConnected ? 'connected' : initiallyExpired ? 'expired' : 'idle'
);
+16
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
deleteConnection,
disableTrigger,
enableTrigger,
listAgentReadyToolkits,
@@ -100,6 +101,21 @@ describe('composioApi trigger wrappers', () => {
});
expect(out.deleted).toBe(true);
});
it('deleteConnection forwards clear_memory only when requested', async () => {
mockCallCoreRpc.mockResolvedValue({
result: { deleted: true, memory_chunks_deleted: 3 },
logs: [],
});
const out = await deleteConnection('conn-1', { clearMemory: true });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.composio_delete_connection',
params: { connection_id: 'conn-1', clear_memory: true },
});
expect(out.memory_chunks_deleted).toBe(3);
});
});
describe('syncConnection', () => {
+9 -2
View File
@@ -111,10 +111,17 @@ export async function authorize(
* Delete an existing Composio connection. Backend verifies ownership
* before forwarding to Composio.
*/
export async function deleteConnection(connectionId: string): Promise<ComposioDeleteResponse> {
export async function deleteConnection(
connectionId: string,
options?: { clearMemory?: boolean }
): Promise<ComposioDeleteResponse> {
const params: { connection_id: string; clear_memory?: boolean } = { connection_id: connectionId };
if (options?.clearMemory) {
params.clear_memory = true;
}
const raw = await callCoreRpc<unknown>({
method: 'openhuman.composio_delete_connection',
params: { connection_id: connectionId },
params,
});
return unwrapCliEnvelope<ComposioDeleteResponse>(raw);
}
+1
View File
@@ -47,6 +47,7 @@ export interface ComposioAuthorizeResponse {
export interface ComposioDeleteResponse {
deleted: boolean;
memory_chunks_deleted?: number;
}
export interface ComposioToolFunction {
+3
View File
@@ -299,6 +299,9 @@ const ar1: TranslationMap = {
'accounts.respondQueue': 'قائمة الردود',
'accounts.disconnect': 'قطع الاتصال',
'accounts.disconnectConfirm': 'هل أنت متأكد من أنك تريد قطع الاتصال بهذا الحساب؟',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'البحث في الحسابات...',
'channels.title': 'القنوات',
'channels.configure': 'ضبط القناة',
+3
View File
@@ -306,6 +306,9 @@ const bn1: TranslationMap = {
'accounts.respondQueue': 'রেসপন্ড কিউ',
'accounts.disconnect': 'সংযোগ বিচ্ছিন্ন',
'accounts.disconnectConfirm': 'আপনি কি এই অ্যাকাউন্ট সংযোগ বিচ্ছিন্ন করতে চান?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'অ্যাকাউন্ট খুঁজুন...',
'channels.title': 'চ্যানেল',
'channels.configure': 'চ্যানেল কনফিগার করুন',
+3
View File
@@ -351,6 +351,9 @@ const de1: TranslationMap = {
'accounts.respondQueue': 'Antwortwarteschlange',
'accounts.disconnect': 'Trennen',
'accounts.disconnectConfirm': 'Möchtest du dieses Konto wirklich trennen?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Konten durchsuchen...',
'channels.title': 'Kanäle',
'channels.configure': 'Kanal konfigurieren',
+3
View File
@@ -608,6 +608,9 @@ const en1: TranslationMap = {
'accounts.respondQueue': 'Respond Queue',
'accounts.disconnect': 'Disconnect',
'accounts.disconnectConfirm': 'Are you sure you want to disconnect this account?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Search accounts...',
'channels.title': 'Channels',
'channels.configure': 'Configure Channel',
+3
View File
@@ -313,6 +313,9 @@ const es1: TranslationMap = {
'accounts.respondQueue': 'Cola de respuestas',
'accounts.disconnect': 'Desconectar',
'accounts.disconnectConfirm': '¿Seguro que quieres desconectar esta cuenta?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Buscar cuentas...',
'channels.title': 'Canales',
'channels.configure': 'Configurar canal',
+3
View File
@@ -314,6 +314,9 @@ const fr1: TranslationMap = {
'accounts.respondQueue': 'File de réponses',
'accounts.disconnect': 'Déconnecter',
'accounts.disconnectConfirm': 'Es-tu sûr de vouloir déconnecter ce compte ?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Rechercher des comptes…',
'channels.title': 'Canaux',
'channels.configure': 'Configurer le canal',
+3
View File
@@ -303,6 +303,9 @@ const hi1: TranslationMap = {
'accounts.respondQueue': 'रिस्पॉन्ड क्यू',
'accounts.disconnect': 'डिसकनेक्ट करें',
'accounts.disconnectConfirm': 'क्या आप वाकई इस अकाउंट को डिसकनेक्ट करना चाहते हैं?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'अकाउंट सर्च करें...',
'channels.title': 'चैनल',
'channels.configure': 'चैनल कॉन्फिगर करें',
+3
View File
@@ -307,6 +307,9 @@ const id1: TranslationMap = {
'accounts.respondQueue': 'Antrean Respons',
'accounts.disconnect': 'Putuskan',
'accounts.disconnectConfirm': 'Yakin ingin memutuskan akun ini?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Cari akun...',
'channels.title': 'Kanal',
'channels.configure': 'Konfigurasi Kanal',
+3
View File
@@ -310,6 +310,9 @@ const it1: TranslationMap = {
'accounts.respondQueue': 'Coda di risposta',
'accounts.disconnect': 'Disconnetti',
'accounts.disconnectConfirm': 'Sei sicuro di voler disconnettere questo account?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Cerca account...',
'channels.title': 'Canali',
'channels.configure': 'Configura canale',
+3
View File
@@ -306,6 +306,9 @@ const ko1: TranslationMap = {
'accounts.respondQueue': '응답 대기열',
'accounts.disconnect': '연결 해제',
'accounts.disconnectConfirm': '이 계정의 연결을 해제하시겠습니까?',
'accounts.disconnectClearMemory': '이 소스의 메모리도 삭제',
'accounts.disconnectClearMemoryHint':
'이 연결과 연관된 로컬 메모리 조각을 영구적으로 삭제합니다.',
'accounts.searchAccounts': '계정 검색...',
'channels.title': '채널',
'channels.configure': '채널 구성',
+3
View File
@@ -313,6 +313,9 @@ const pt1: TranslationMap = {
'accounts.respondQueue': 'Fila de Respostas',
'accounts.disconnect': 'Desconectar',
'accounts.disconnectConfirm': 'Tem certeza de que deseja desconectar esta conta?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Pesquisar contas...',
'channels.title': 'Canais',
'channels.configure': 'Configurar Canal',
+3
View File
@@ -306,6 +306,9 @@ const ru1: TranslationMap = {
'accounts.respondQueue': 'Очередь ответов',
'accounts.disconnect': 'Отключить',
'accounts.disconnectConfirm': 'Ты уверен, что хочешь отключить этот аккаунт?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Поиск аккаунтов...',
'channels.title': 'Каналы',
'channels.configure': 'Настроить канал',
+3
View File
@@ -291,6 +291,9 @@ const zhCN1: TranslationMap = {
'accounts.respondQueue': '回复队列',
'accounts.disconnect': '断开连接',
'accounts.disconnectConfirm': '确定要断开此账户的连接吗?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': '搜索账户...',
'channels.title': '渠道',
'channels.configure': '配置渠道',
+3
View File
@@ -432,6 +432,9 @@ const en: TranslationMap = {
'accounts.respondQueue': 'Respond Queue',
'accounts.disconnect': 'Disconnect',
'accounts.disconnectConfirm': 'Are you sure you want to disconnect this account?',
'accounts.disconnectClearMemory': 'Also delete memory from this source',
'accounts.disconnectClearMemoryHint':
'Permanently removes local memory chunks linked to this connection.',
'accounts.searchAccounts': 'Search accounts...',
// Channels
@@ -43,6 +43,20 @@ describe('channelConnectionsApi', () => {
});
});
it('forwards clear_memory when disconnecting a channel with memory cleanup enabled', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: { disconnected: true, memory_chunks_deleted: 2 },
logs: ['removed credentials'],
});
await channelConnectionsApi.disconnectChannel('discord', 'bot_token', { clearMemory: true });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.channels_disconnect',
params: { channel: 'discord', authMode: 'bot_token', clearMemory: true },
});
});
it('rejects invalid Discord guild list shape', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: { guilds: [] },
+17 -2
View File
@@ -15,6 +15,10 @@ interface ConnectChannelPayload {
credentials?: Record<string, string>;
}
interface DisconnectChannelOptions {
clearMemory?: boolean;
}
export interface TelegramLoginStartResult {
linkToken: string;
telegramUrl: string;
@@ -146,8 +150,19 @@ export const channelConnectionsApi = {
},
/** Disconnect a channel for a given auth mode. */
disconnectChannel: async (channel: ChannelType, authMode: ChannelAuthMode): Promise<void> => {
await callCoreRpc({ method: 'openhuman.channels_disconnect', params: { channel, authMode } });
disconnectChannel: async (
channel: ChannelType,
authMode: ChannelAuthMode,
options?: DisconnectChannelOptions
): Promise<void> => {
const params: { channel: ChannelType; authMode: ChannelAuthMode; clearMemory?: boolean } = {
channel,
authMode,
};
if (options?.clearMemory) {
params.clearMemory = true;
}
await callCoreRpc({ method: 'openhuman.channels_disconnect', params });
},
/** Test channel credentials without persisting. */
+22
View File
@@ -8,6 +8,8 @@ use crate::api::jwt::get_session_token;
use crate::api::rest::BackendOAuthClient;
use crate::openhuman::config::{Config, DiscordConfig, IMessageConfig, TelegramConfig};
use crate::openhuman::credentials;
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
use crate::openhuman::memory_store::chunks::types::SourceKind;
use crate::rpc::RpcOutcome;
use super::definitions::{
@@ -353,6 +355,7 @@ pub async fn disconnect_channel(
config: &Config,
channel_id: &str,
auth_mode: ChannelAuthMode,
clear_memory: bool,
) -> Result<RpcOutcome<Value>, String> {
// Verify channel exists.
find_channel_definition(channel_id).ok_or_else(|| format!("unknown channel: {channel_id}"))?;
@@ -404,17 +407,36 @@ pub async fn disconnect_channel(
}
}
let memory_chunks_deleted = if clear_memory {
clear_channel_memory(config, channel_id).map_err(|e| {
format!("channel disconnected, but failed to clear memory chunks: {e:#}")
})?
} else {
0
};
Ok(RpcOutcome::single_log(
json!({
"channel": channel_id,
"auth_mode": auth_mode,
"disconnected": true,
"restart_required": true,
"memory_chunks_deleted": memory_chunks_deleted,
}),
format!("removed credentials for {}", provider_key),
))
}
fn clear_channel_memory(config: &Config, channel_id: &str) -> anyhow::Result<usize> {
let exact = memory_tree_store::delete_chunks_by_source(config, SourceKind::Chat, channel_id)?;
let prefixed = memory_tree_store::delete_chunks_by_source_prefix(
config,
SourceKind::Chat,
&format!("{channel_id}:"),
)?;
Ok(exact + prefixed)
}
/// Get connection status for one or all channels.
pub async fn channel_status(
config: &Config,
@@ -1,4 +1,9 @@
use super::*;
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
use chrono::{TimeZone, Utc};
use tempfile::tempdir;
fn isolated_test_config() -> (tempfile::TempDir, Config) {
@@ -10,6 +15,29 @@ fn isolated_test_config() -> (tempfile::TempDir, Config) {
(tmp, config)
}
fn sample_chat_chunk(source_id: &str, seq: u32) -> Chunk {
let ts = Utc
.timestamp_millis_opt(1_700_000_000_000 + i64::from(seq))
.unwrap();
Chunk {
id: chunk_id(SourceKind::Chat, source_id, seq, "channel memory"),
content: format!("channel memory {source_id} {seq}"),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: source_id.to_string(),
owner: "alice@example.com".to_string(),
timestamp: ts,
time_range: (ts, ts),
tags: vec!["channel".to_string()],
source_ref: Some(SourceRef::new(format!("discord://{source_id}/{seq}"))),
},
token_count: 12,
seq_in_source: seq,
created_at: ts,
partial_message: false,
}
}
#[tokio::test]
async fn list_channels_returns_definitions() {
let result = list_channels().await.unwrap();
@@ -138,7 +166,7 @@ async fn disconnect_discord_bot_token_clears_runtime_config() {
.await
.expect("preloaded config should be persisted");
disconnect_channel(&config, "discord", ChannelAuthMode::BotToken)
disconnect_channel(&config, "discord", ChannelAuthMode::BotToken, false)
.await
.expect("discord disconnect should succeed");
@@ -154,6 +182,49 @@ async fn disconnect_discord_bot_token_clears_runtime_config() {
);
}
#[tokio::test]
async fn disconnect_channel_clear_memory_deletes_matching_chat_sources() {
let (_tmp, mut config) = isolated_test_config();
config.channels_config.discord = Some(DiscordConfig {
bot_token: "discord-token-abc".to_string(),
guild_id: Some("guild-1".to_string()),
channel_id: Some("channel-2".to_string()),
allowed_users: vec![],
listen_to_bots: false,
mention_only: false,
});
config
.save()
.await
.expect("preloaded config should be persisted");
let target_a = sample_chat_chunk("discord:guild-1", 0);
let target_b = sample_chat_chunk("discord:guild-1:channel-2", 1);
let unrelated = sample_chat_chunk("telegram:chat-1", 0);
memory_tree_store::upsert_chunks(&config, &[target_a, target_b, unrelated])
.expect("chunks should seed");
let result = disconnect_channel(&config, "discord", ChannelAuthMode::BotToken, true)
.await
.expect("discord disconnect should succeed");
assert_eq!(
result.value["memory_chunks_deleted"].as_u64(),
Some(2),
"disconnect should report deleted memory chunks"
);
let remaining = memory_tree_store::list_chunks(
&config,
&memory_tree_store::ListChunksQuery {
source_kind: Some(SourceKind::Chat),
..Default::default()
},
)
.expect("chunks should list");
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].metadata.source_id, "telegram:chat-1");
}
#[tokio::test]
async fn test_channel_validates_fields() {
let config = Config::default();
@@ -349,7 +420,7 @@ async fn disconnect_imessage_clears_runtime_config() {
.await
.expect("preloaded config should be persisted");
disconnect_channel(&config, "imessage", ChannelAuthMode::ManagedDm)
disconnect_channel(&config, "imessage", ChannelAuthMode::ManagedDm, false)
.await
.expect("imessage disconnect should succeed");
+10 -2
View File
@@ -36,6 +36,8 @@ struct ConnectParams {
struct DisconnectParams {
channel: String,
auth_mode: String,
#[serde(default)]
clear_memory: bool,
}
#[derive(Debug, Deserialize)]
@@ -265,10 +267,16 @@ pub fn schemas(function: &str) -> ControllerSchema {
"disconnect" => ControllerSchema {
namespace: "channels",
function: "disconnect",
description: "Disconnect a channel and remove stored credentials.",
description: "Disconnect a channel and optionally remove source-scoped memory.",
inputs: vec![
required_string("channel", "Channel identifier."),
required_string("authMode", "Auth mode to disconnect."),
FieldSchema {
name: "clearMemory",
ty: TypeSchema::Bool,
comment: "When true, delete memory chunks ingested from this channel.",
required: false,
},
],
outputs: vec![json_output("result", "Disconnect result.")],
},
@@ -480,7 +488,7 @@ fn handle_disconnect(params: Map<String, Value>) -> ControllerFuture {
.auth_mode
.parse()
.map_err(|e: String| format!("invalid authMode: {e}"))?;
to_json(ops::disconnect_channel(&config, p.channel.trim(), mode).await?)
to_json(ops::disconnect_channel(&config, p.channel.trim(), mode, p.clear_memory).await?)
})
}
@@ -281,6 +281,18 @@ fn deserialize_disconnect_params() {
}))
.unwrap();
assert_eq!(params.channel, "discord");
assert!(!params.clear_memory);
}
#[test]
fn deserialize_disconnect_params_accepts_clear_memory() {
let params: DisconnectParams = serde_json::from_value(json!({
"channel": "discord",
"authMode": "bot_token",
"clearMemory": true
}))
.unwrap();
assert!(params.clear_memory);
}
#[test]
+200 -8
View File
@@ -9,6 +9,10 @@
//! agent harness) when they need composio data at runtime.
use crate::openhuman::config::Config;
use crate::openhuman::memory::MemoryClient;
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
use crate::openhuman::memory_store::chunks::types::SourceKind;
use crate::openhuman::memory_store::content::raw::slug_account_email;
use crate::rpc::RpcOutcome;
/// Result alias used by every `composio_*` op in this module.
@@ -22,12 +26,12 @@ type OpResult<T> = std::result::Result<T, String>;
use std::sync::Arc;
use super::client::{
build_composio_client, create_composio_client, direct_authorize, direct_list_connections,
direct_list_tools, ComposioClient, ComposioClientKind,
build_composio_client, create_composio_client, direct_list_connections, direct_list_tools,
ComposioClient, ComposioClientKind,
};
use super::providers::{
agent_ready_toolkits, capability_matrix, get_provider, ProviderContext, ProviderUserProfile,
SyncOutcome, SyncReason,
agent_ready_toolkits, capability_matrix, get_provider, sync_state::SyncState, ProviderContext,
ProviderUserProfile, SyncOutcome, SyncReason,
};
use super::types::{
ComposioActiveTriggersResponse, ComposioAuthorizeResponse, ComposioAvailableTriggersResponse,
@@ -407,16 +411,48 @@ pub async fn composio_authorize(
pub async fn composio_delete_connection(
config: &Config,
connection_id: &str,
clear_memory: bool,
) -> OpResult<RpcOutcome<ComposioDeleteResponse>> {
tracing::debug!(connection_id = %connection_id, "[composio] rpc delete_connection");
let client = resolve_client(config)?;
let toolkit = resolve_toolkit_for_connection(&client, connection_id)
.await
.ok();
let resp = client.delete_connection(connection_id).await.map_err(|e| {
let toolkit = match resolve_toolkit_for_connection(&client, connection_id).await {
Ok(toolkit) => Some(toolkit),
Err(error) if clear_memory => {
return Err(format!(
"[composio] delete_connection cannot clear memory without resolving toolkit: {error}"
));
}
Err(_) => None,
};
let memory_targets = if clear_memory {
composio_memory_targets_for_connection(config, toolkit.as_deref(), connection_id)
.await
.map_err(|error| {
format!("[composio] delete_connection cannot enumerate memory targets: {error:#}")
})?
} else {
Vec::new()
};
let mut resp = client.delete_connection(connection_id).await.map_err(|e| {
report_composio_op_error("delete_connection", &e);
format!("[composio] delete_connection failed: {e:#}")
})?;
let mut memory_chunks_deleted = 0;
let mut memory_clear_errors = Vec::new();
for target in &memory_targets {
match target.delete(config) {
Ok(deleted) => {
memory_chunks_deleted += deleted;
}
Err(error) => {
memory_clear_errors.push(format!(
"[composio] connection deleted, but failed to clear memory chunks for {}: {error:#}",
target.label()
));
}
}
}
resp.memory_chunks_deleted = memory_chunks_deleted;
if let Some(toolkit) = toolkit.as_deref() {
let deleted =
super::providers::profile::delete_connected_identity_facets(toolkit, connection_id);
@@ -477,12 +513,168 @@ pub async fn composio_delete_connection(
);
}
}
if !memory_clear_errors.is_empty() {
return Err(memory_clear_errors.join("; "));
}
Ok(RpcOutcome::new(
resp,
vec![format!("composio: connection {connection_id} deleted")],
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MemoryCleanupTarget {
Exact(SourceKind, String),
Prefix(SourceKind, String),
}
impl MemoryCleanupTarget {
fn delete(&self, config: &Config) -> anyhow::Result<usize> {
match self {
Self::Exact(source_kind, source_id) => {
memory_tree_store::delete_chunks_by_source(config, *source_kind, source_id)
}
Self::Prefix(source_kind, source_id_prefix) => {
memory_tree_store::delete_chunks_by_source_prefix(
config,
*source_kind,
source_id_prefix,
)
}
}
}
fn label(&self) -> String {
match self {
Self::Exact(source_kind, source_id) => {
format!("{}:{source_id}", source_kind.as_str())
}
Self::Prefix(source_kind, source_id_prefix) => {
format!("{}:{source_id_prefix}*", source_kind.as_str())
}
}
}
}
async fn composio_memory_targets_for_connection(
config: &Config,
toolkit: Option<&str>,
connection_id: &str,
) -> anyhow::Result<Vec<MemoryCleanupTarget>> {
let Some(toolkit) = toolkit.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(Vec::new());
};
let targets = match toolkit.to_ascii_lowercase().as_str() {
"slack" => vec![MemoryCleanupTarget::Exact(
SourceKind::Chat,
format!("slack:{connection_id}"),
)],
"gmail" => gmail_memory_sources_for_connection(connection_id),
"notion" => notion_memory_targets_for_connection(config, connection_id).await?,
"drive" | "googledrive" | "google_drive" => {
drive_memory_targets_for_connection(connection_id)
}
_ => Vec::new(),
};
Ok(targets)
}
fn gmail_memory_sources_for_connection(connection_id: &str) -> Vec<MemoryCleanupTarget> {
let normalized_connection_id =
super::providers::profile::normalize_connection_identifier(connection_id);
let mut sources = Vec::new();
for identity in super::providers::profile::load_connected_identities() {
if identity.source != "gmail" || identity.identifier != normalized_connection_id {
continue;
}
let Some(email) = identity
.email
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
continue;
};
let source = MemoryCleanupTarget::Exact(
SourceKind::Email,
format!("gmail:{}", slug_account_email(email)),
);
if !sources.iter().any(|existing| existing == &source) {
sources.push(source);
}
}
sources
}
async fn notion_memory_targets_for_connection(
config: &Config,
connection_id: &str,
) -> anyhow::Result<Vec<MemoryCleanupTarget>> {
let mut targets = connection_scoped_document_targets("notion", connection_id);
let memory = Arc::new(
MemoryClient::from_workspace_dir(config.workspace_dir.clone()).map_err(|error| {
anyhow::anyhow!(
"failed to open memory client for notion cleanup target discovery: {error}"
)
})?,
);
let state = SyncState::load(&memory, "notion", connection_id)
.await
.map_err(|error| {
anyhow::anyhow!("failed to load notion sync state for memory cleanup: {error}")
})?;
for raw_id in state.synced_ids {
let Some(page_id) = notion_synced_page_id(&raw_id) else {
continue;
};
targets.push(MemoryCleanupTarget::Exact(
SourceKind::Document,
format!("notion:{page_id}"),
));
targets.push(MemoryCleanupTarget::Exact(
SourceKind::Document,
format!("composio-notion-page-{page_id}"),
));
}
Ok(dedupe_memory_targets(targets))
}
fn drive_memory_targets_for_connection(connection_id: &str) -> Vec<MemoryCleanupTarget> {
["drive", "googledrive", "google_drive"]
.into_iter()
.flat_map(|prefix| connection_scoped_document_targets(prefix, connection_id))
.collect()
}
fn connection_scoped_document_targets(
prefix: &str,
connection_id: &str,
) -> Vec<MemoryCleanupTarget> {
vec![
MemoryCleanupTarget::Exact(SourceKind::Document, format!("{prefix}:{connection_id}")),
MemoryCleanupTarget::Prefix(SourceKind::Document, format!("{prefix}:{connection_id}:")),
MemoryCleanupTarget::Prefix(SourceKind::Document, format!("{prefix}:{connection_id}/")),
]
}
fn notion_synced_page_id(raw_id: &str) -> Option<String> {
let page_id = raw_id.split_once('@').map_or(raw_id, |(id, _)| id).trim();
(!page_id.is_empty()).then(|| page_id.to_string())
}
fn dedupe_memory_targets(targets: Vec<MemoryCleanupTarget>) -> Vec<MemoryCleanupTarget> {
let mut unique = Vec::new();
for target in targets {
if !unique.contains(&target) {
unique.push(target);
}
}
unique
}
// ── Tools ───────────────────────────────────────────────────────────
pub async fn composio_list_tools(
+155 -2
View File
@@ -113,7 +113,7 @@ async fn composio_authorize_errors_without_session() {
async fn composio_delete_connection_errors_without_session() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(&tmp);
let err = composio_delete_connection(&config, "c-1")
let err = composio_delete_connection(&config, "c-1", false)
.await
.unwrap_err();
assert!(err.contains("composio unavailable"));
@@ -231,11 +231,16 @@ fn invalidate_connected_integrations_cache_is_safe_without_prior_insert() {
// ── Mock-backend integration tests for ops ─────────────────────
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
use axum::{
extract::{Path, Query},
routing::{get, post},
Json, Router,
};
use chrono::{TimeZone, Utc};
use serde_json::{json, Value};
use std::collections::HashMap;
@@ -310,6 +315,29 @@ fn config_with_backend(tmp: &tempfile::TempDir, base: String) -> Config {
c
}
fn sample_memory_chunk(source_kind: SourceKind, source_id: &str, seq: u32) -> Chunk {
let ts = Utc
.timestamp_millis_opt(1_700_000_000_000 + i64::from(seq))
.unwrap();
Chunk {
id: chunk_id(source_kind, source_id, seq, "composio memory"),
content: format!("composio memory {source_id} {seq}"),
metadata: Metadata {
source_kind,
source_id: source_id.to_string(),
owner: "alice@example.com".to_string(),
timestamp: ts,
time_range: (ts, ts),
tags: vec!["composio".to_string()],
source_ref: Some(SourceRef::new(format!("composio://{source_id}/{seq}"))),
},
token_count: 12,
seq_in_source: seq,
created_at: ts,
partial_message: false,
}
}
#[tokio::test]
async fn composio_list_toolkits_via_mock() {
let app = Router::new().route(
@@ -433,10 +461,135 @@ async fn composio_delete_connection_via_mock() {
let base = start_mock_backend(app).await;
let tmp = tempfile::tempdir().unwrap();
let config = config_with_backend(&tmp, base);
let outcome = composio_delete_connection(&config, "c1").await.unwrap();
let outcome = composio_delete_connection(&config, "c1", false)
.await
.unwrap();
assert!(outcome.value.deleted);
}
#[tokio::test]
async fn composio_delete_connection_clear_memory_deletes_slack_source() {
let app = Router::new()
.route(
"/agent-integrations/composio/connections",
get(|| async {
Json(json!({
"success": true,
"data": {"connections": [
{"id":"c1","toolkit":"slack","status":"ACTIVE"}
]}
}))
}),
)
.route(
"/agent-integrations/composio/connections/{id}",
axum::routing::delete(|Path(_id): Path<String>| async move {
Json(json!({"success": true, "data": {"deleted": true}}))
}),
);
let base = start_mock_backend(app).await;
let tmp = tempfile::tempdir().unwrap();
let config = config_with_backend(&tmp, base);
let target = sample_memory_chunk(SourceKind::Chat, "slack:c1", 0);
let unrelated = sample_memory_chunk(SourceKind::Chat, "slack:c2", 0);
memory_tree_store::upsert_chunks(&config, &[target, unrelated]).expect("chunks should seed");
let outcome = composio_delete_connection(&config, "c1", true)
.await
.unwrap();
assert!(outcome.value.deleted);
assert_eq!(outcome.value.memory_chunks_deleted, 1);
let remaining = memory_tree_store::list_chunks(
&config,
&memory_tree_store::ListChunksQuery {
source_kind: Some(SourceKind::Chat),
..Default::default()
},
)
.expect("chunks should list");
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].metadata.source_id, "slack:c2");
}
#[tokio::test]
async fn notion_cleanup_targets_include_synced_page_sources() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(&tmp);
let memory = std::sync::Arc::new(
MemoryClient::from_workspace_dir(config.workspace_dir.clone())
.expect("memory client should initialise"),
);
let mut state = SyncState::new("notion", "conn-1");
state.mark_synced("page-a@2026-01-01T00:00:00Z");
state.mark_synced("page-b");
state.save(&memory).await.expect("sync state should save");
let targets = composio_memory_targets_for_connection(&config, Some("notion"), "conn-1")
.await
.expect("notion cleanup targets should resolve");
assert!(targets.contains(&MemoryCleanupTarget::Exact(
SourceKind::Document,
"notion:page-a".to_string()
)));
assert!(targets.contains(&MemoryCleanupTarget::Exact(
SourceKind::Document,
"notion:page-b".to_string()
)));
assert!(targets.contains(&MemoryCleanupTarget::Exact(
SourceKind::Document,
"composio-notion-page-page-a".to_string()
)));
}
#[tokio::test]
async fn notion_cleanup_targets_surface_corrupt_sync_state() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(&tmp);
let memory = std::sync::Arc::new(
MemoryClient::from_workspace_dir(config.workspace_dir.clone())
.expect("memory client should initialise"),
);
memory
.kv_set(
Some(super::super::providers::sync_state::KV_NAMESPACE),
"notion:conn-1",
&serde_json::json!({ "toolkit": 42 }),
)
.await
.expect("corrupt sync state should be written");
let err = composio_memory_targets_for_connection(&config, Some("notion"), "conn-1")
.await
.expect_err("corrupt sync state should surface");
assert!(err.to_string().contains("failed to load notion sync state"));
}
#[tokio::test]
async fn drive_cleanup_targets_are_connection_scoped() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(&tmp);
let targets = composio_memory_targets_for_connection(&config, Some("google_drive"), "conn-1")
.await
.expect("drive cleanup targets should resolve");
assert!(targets.contains(&MemoryCleanupTarget::Exact(
SourceKind::Document,
"drive:conn-1".to_string()
)));
assert!(targets.contains(&MemoryCleanupTarget::Prefix(
SourceKind::Document,
"googledrive:conn-1:".to_string()
)));
assert!(targets.contains(&MemoryCleanupTarget::Prefix(
SourceKind::Document,
"google_drive:conn-1/".to_string()
)));
}
#[tokio::test]
async fn composio_get_user_profile_via_mock_returns_provider_profile() {
use crate::openhuman::config::TEST_ENV_LOCK;
+33 -14
View File
@@ -278,19 +278,35 @@ pub fn schemas(function: &str) -> ControllerSchema {
"delete_connection" => ControllerSchema {
namespace: "composio",
function: "delete_connection",
description: "Delete a Composio connection owned by the caller.",
inputs: vec![FieldSchema {
name: "connection_id",
ty: TypeSchema::String,
comment: "Identifier of the connection to delete.",
required: true,
}],
outputs: vec![FieldSchema {
name: "deleted",
ty: TypeSchema::Bool,
comment: "True when the backend confirmed the deletion.",
required: true,
}],
description: "Delete a Composio connection and optionally remove source-scoped memory.",
inputs: vec![
FieldSchema {
name: "connection_id",
ty: TypeSchema::String,
comment: "Identifier of the connection to delete.",
required: true,
},
FieldSchema {
name: "clear_memory",
ty: TypeSchema::Bool,
comment: "When true, delete memory chunks ingested from this connection.",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "deleted",
ty: TypeSchema::Bool,
comment: "True when the backend confirmed the deletion.",
required: true,
},
FieldSchema {
name: "memory_chunks_deleted",
ty: TypeSchema::U64,
comment: "Number of memory chunks deleted for this connection.",
required: true,
},
],
},
"list_tools" => ControllerSchema {
namespace: "composio",
@@ -751,7 +767,10 @@ fn handle_delete_connection(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let connection_id = read_required_non_empty(&params, "connection_id")?;
to_json(super::ops::composio_delete_connection(&config, &connection_id).await?)
let clear_memory = read_optional::<bool>(&params, "clear_memory")?.unwrap_or(false);
to_json(
super::ops::composio_delete_connection(&config, &connection_id, clear_memory).await?,
)
})
}
+2
View File
@@ -170,6 +170,8 @@ pub struct ComposioAuthorizeResponse {
pub struct ComposioDeleteResponse {
#[serde(default)]
pub deleted: bool,
#[serde(default)]
pub memory_chunks_deleted: usize,
}
// ── Tools ───────────────────────────────────────────────────────────
+184 -1
View File
@@ -35,6 +35,7 @@ use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use crate::openhuman::config::Config;
use crate::openhuman::memory::util::redact;
use crate::openhuman::memory_store::chunks::types::{Chunk, Metadata, SourceKind, SourceRef};
use crate::openhuman::memory_store::content::StagedChunk;
@@ -46,7 +47,7 @@ const MAX_LIST_LIMIT: usize = 10_000;
// contention (4 job workers + scheduler + ingest producers all writing the
// same `memory_tree/chunks.db`) is absorbed inside rusqlite instead of
// surfacing as `SQLITE_BUSY` to callers. Workers still treat busy as a
// soft signal (see `memory::tree::jobs::worker`) so even if this is
// soft signal (see `memory_tree::jobs::worker`) so even if this is
// exceeded, the only effect is a one-poll backoff — but 15s is
// comfortably above realistic peer-write durations and shrinks the rate
// at which we have to fall back to that path. The previous 5s was tight
@@ -702,6 +703,188 @@ pub(crate) fn claim_source_ingest_tx(
Ok(inserted > 0)
}
/// Delete all chunk rows for one exact `(source_kind, source_id)` and clear
/// dependent source-local indexes. Returns the number of chunk rows removed.
pub fn delete_chunks_by_source(
config: &Config,
source_kind: SourceKind,
source_id: &str,
) -> Result<usize> {
delete_chunks_by_source_filter(config, source_kind, |candidate| candidate == source_id)
}
/// Delete all chunk rows whose source id starts with `source_id_prefix`.
///
/// This is intentionally a Rust-side prefix filter rather than a SQL `LIKE`
/// expression so provider ids containing `_` / `%` are treated literally.
pub fn delete_chunks_by_source_prefix(
config: &Config,
source_kind: SourceKind,
source_id_prefix: &str,
) -> Result<usize> {
delete_chunks_by_source_filter(config, source_kind, |candidate| {
candidate.starts_with(source_id_prefix)
})
}
fn delete_chunks_by_source_filter(
config: &Config,
source_kind: SourceKind,
matches_source: impl Fn(&str) -> bool,
) -> Result<usize> {
let mut content_paths = Vec::new();
let deleted = with_connection(config, |conn| {
let tx = conn.unchecked_transaction()?;
let chunks = {
let mut stmt = tx.prepare(
"SELECT id, source_id, content_path
FROM mem_tree_chunks
WHERE source_kind = ?1",
)?;
let rows = stmt.query_map(params![source_kind.as_str()], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
rows.filter_map(|row| match row {
Ok((id, source_id, content_path)) if matches_source(&source_id) => {
Some(Ok((id, content_path)))
}
Ok(_) => None,
Err(error) => Some(Err(error)),
})
.collect::<rusqlite::Result<Vec<_>>>()
.context("Failed to collect memory_tree chunks by source")?
};
for (chunk_id, content_path) in &chunks {
tx.execute(
"DELETE FROM mem_tree_score WHERE chunk_id = ?1",
params![chunk_id],
)?;
tx.execute(
"DELETE FROM mem_tree_entity_index WHERE node_id = ?1",
params![chunk_id],
)?;
tx.execute(
"DELETE FROM mem_tree_chunk_embeddings WHERE chunk_id = ?1",
params![chunk_id],
)?;
tx.execute(
"DELETE FROM mem_tree_chunk_reembed_skipped WHERE chunk_id = ?1",
params![chunk_id],
)?;
tx.execute(
"DELETE FROM mem_tree_chunks WHERE id = ?1",
params![chunk_id],
)?;
if let Some(path) = content_path.as_ref().filter(|path| !path.is_empty()) {
content_paths.push(path.clone());
}
}
let ingested_sources = {
let mut stmt = tx.prepare(
"SELECT source_id
FROM mem_tree_ingested_sources
WHERE source_kind = ?1",
)?;
let rows =
stmt.query_map(params![source_kind.as_str()], |row| row.get::<_, String>(0))?;
rows.filter_map(|row| match row {
Ok(source_id) if matches_source(&source_id) => Some(Ok(source_id)),
Ok(_) => None,
Err(error) => Some(Err(error)),
})
.collect::<rusqlite::Result<Vec<_>>>()
.context("Failed to collect memory_tree ingested sources")?
};
for source_id in &ingested_sources {
tx.execute(
"DELETE FROM mem_tree_ingested_sources
WHERE source_kind = ?1 AND source_id = ?2",
params![source_kind.as_str(), source_id],
)?;
}
let deleted = chunks.len();
tx.commit()?;
Ok(deleted)
})?;
remove_chunk_content_files(config, &content_paths);
Ok(deleted)
}
fn remove_chunk_content_files(config: &Config, content_paths: &[String]) {
use std::path::{Component, Path};
let root = config.memory_tree_content_root();
let canonical_root = match std::fs::canonicalize(&root) {
Ok(path) => path,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
log::warn!(
"[memory_tree::store] failed to resolve content root {}: {error}",
root.display(),
);
}
return;
}
};
for rel in content_paths {
let rel_path = Path::new(rel);
let has_escape_component = rel_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
if has_escape_component {
log::warn!(
"[memory_tree::store] refusing to remove chunk file with unsafe content_path path_hash={}",
redact::redact(rel),
);
continue;
}
let path = root.join(rel_path);
let resolved_path = match std::fs::canonicalize(&path) {
Ok(path) => path,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
log::warn!(
"[memory_tree::store] failed to resolve chunk file path_hash={}: {error}",
redact::redact(rel),
);
}
continue;
}
};
if !resolved_path.starts_with(&canonical_root) {
log::warn!(
"[memory_tree::store] refusing to remove chunk file outside content root path_hash={}",
redact::redact(rel),
);
continue;
}
if let Err(error) = std::fs::remove_file(&path) {
if error.kind() != std::io::ErrorKind::NotFound {
log::warn!(
"[memory_tree::store] failed to remove chunk file path_hash={}: {error}",
redact::redact(rel),
);
}
}
}
}
fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<Chunk> {
let id: String = row.get(0)?;
let source_kind_s: String = row.get(1)?;
@@ -218,6 +218,151 @@ fn list_limit_is_clamped_to_sane_range() {
assert_eq!(huge_limit.len(), 3);
}
#[test]
fn delete_chunks_by_source_removes_chunks_side_rows_and_ingest_gate() {
let (_tmp, cfg) = test_config();
let target_a = sample_chunk("slack:c-1", 0, 1_700_000_000_000);
let target_b = sample_chunk("slack:c-1", 1, 1_700_000_001_000);
let other = sample_chunk("slack:c-2", 0, 1_700_000_002_000);
upsert_chunks(&cfg, &[target_a.clone(), target_b.clone(), other.clone()]).unwrap();
with_connection(&cfg, |conn| {
let tx = conn.unchecked_transaction()?;
for chunk in [&target_a, &target_b, &other] {
tx.execute(
"INSERT INTO mem_tree_score (
chunk_id, total, token_count_signal, unique_words_signal,
metadata_weight, source_weight, interaction_weight,
entity_density, dropped, reason, computed_at_ms
) VALUES (?1, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0, NULL, 1700000000000)",
params![chunk.id],
)?;
tx.execute(
"INSERT INTO mem_tree_entity_index (
entity_id, node_id, node_kind, entity_kind, surface, score, timestamp_ms
) VALUES (?1, ?2, 'chunk', 'person', 'chat', 0.9, 1700000000000)",
params![format!("entity:{}", chunk.id), chunk.id],
)?;
tx.execute(
"INSERT INTO mem_tree_chunk_embeddings (
chunk_id, model_signature, vector, dim, created_at
) VALUES (?1, 'test/model@3', ?2, 3, 1700000000.0)",
params![chunk.id, vec![1_u8, 2, 3]],
)?;
tx.execute(
"INSERT INTO mem_tree_chunk_reembed_skipped (
chunk_id, model_signature, reason, skipped_at_ms
) VALUES (?1, 'test/model@3', 'terminal', 1700000000000)",
params![chunk.id],
)?;
}
assert!(claim_source_ingest_tx(
&tx,
SourceKind::Chat,
"slack:c-1",
1_700_000_000_000
)?);
assert!(claim_source_ingest_tx(
&tx,
SourceKind::Chat,
"slack:c-2",
1_700_000_000_000
)?);
tx.commit()?;
Ok(())
})
.unwrap();
let deleted = delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:c-1").unwrap();
assert_eq!(deleted, 2);
assert_eq!(count_chunks(&cfg).unwrap(), 1);
assert!(get_chunk(&cfg, &target_a.id).unwrap().is_none());
assert!(get_chunk(&cfg, &target_b.id).unwrap().is_none());
assert!(get_chunk(&cfg, &other.id).unwrap().is_some());
assert!(!is_source_ingested(&cfg, SourceKind::Chat, "slack:c-1").unwrap());
assert!(is_source_ingested(&cfg, SourceKind::Chat, "slack:c-2").unwrap());
with_connection(&cfg, |conn| {
let count_by_table = |table: &str| -> rusqlite::Result<i64> {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
};
assert_eq!(count_by_table("mem_tree_score")?, 1);
assert_eq!(count_by_table("mem_tree_entity_index")?, 1);
assert_eq!(count_by_table("mem_tree_chunk_embeddings")?, 1);
assert_eq!(count_by_table("mem_tree_chunk_reembed_skipped")?, 1);
Ok(())
})
.unwrap();
}
#[test]
fn delete_chunks_by_source_removes_safe_content_files_but_rejects_escape_paths() {
let (_tmp, cfg) = test_config();
let safe = sample_chunk("slack:c-1", 0, 1_700_000_000_000);
let unsafe_chunk = sample_chunk("slack:c-1", 1, 1_700_000_001_000);
upsert_chunks(&cfg, &[safe.clone(), unsafe_chunk.clone()]).unwrap();
let content_root = cfg.memory_tree_content_root();
let safe_rel = "chunks/safe.md";
let safe_path = content_root.join(safe_rel);
std::fs::create_dir_all(safe_path.parent().unwrap()).unwrap();
std::fs::write(&safe_path, "safe").unwrap();
let outside_path = content_root.parent().unwrap().join("outside.md");
std::fs::write(&outside_path, "outside").unwrap();
with_connection(&cfg, |conn| {
conn.execute(
"UPDATE mem_tree_chunks SET content_path = ?1 WHERE id = ?2",
params![safe_rel, safe.id],
)?;
conn.execute(
"UPDATE mem_tree_chunks SET content_path = ?1 WHERE id = ?2",
params!["../outside.md", unsafe_chunk.id],
)?;
Ok(())
})
.unwrap();
let deleted = delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:c-1").unwrap();
assert_eq!(deleted, 2);
assert!(!safe_path.exists());
assert!(outside_path.exists());
}
#[cfg(unix)]
#[test]
fn delete_chunks_by_source_removes_symlink_entry_not_target_file() {
let (_tmp, cfg) = test_config();
let linked_chunk = sample_chunk("slack:c-1", 0, 1_700_000_000_000);
upsert_chunks(&cfg, &[linked_chunk.clone()]).unwrap();
let content_root = cfg.memory_tree_content_root();
let target_path = content_root.join("chunks/target.md");
let link_rel = "chunks/link.md";
let link_path = content_root.join(link_rel);
std::fs::create_dir_all(target_path.parent().unwrap()).unwrap();
std::fs::write(&target_path, "target").unwrap();
std::os::unix::fs::symlink("target.md", &link_path).unwrap();
with_connection(&cfg, |conn| {
conn.execute(
"UPDATE mem_tree_chunks SET content_path = ?1 WHERE id = ?2",
params![link_rel, linked_chunk.id],
)?;
Ok(())
})
.unwrap();
let deleted = delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:c-1").unwrap();
assert_eq!(deleted, 1);
assert!(target_path.exists());
assert!(!link_path.exists());
}
#[test]
fn missing_chunk_returns_none() {
let (_tmp, cfg) = test_config();
@@ -506,6 +506,10 @@ fn normalize_token(raw: &str) -> String {
out.trim_matches('_').to_string()
}
pub(crate) fn normalize_connection_identifier(raw: &str) -> String {
normalize_token(raw)
}
fn title_case(raw: &str) -> String {
let mut chars = raw.chars();
match chars.next() {