diff --git a/frontend/src/agents/NewAgent.test.tsx b/frontend/src/agents/NewAgent.test.tsx index 5da313f7..2c7aec76 100644 --- a/frontend/src/agents/NewAgent.test.tsx +++ b/frontend/src/agents/NewAgent.test.tsx @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => { createAgent: vi.fn(() => jsonResponse({ message: 'Name is taken' }, false)), tools: null as unknown[] | null, connections: [] as unknown[], + catalog: [] as unknown[], deleteAgent: vi.fn(() => jsonResponse({})), guardrailsProps: vi.fn(), }; @@ -81,6 +82,8 @@ vi.mock('../api/services/devicesService', () => ({ vi.mock('../api/services/connectorsService', () => ({ default: { listConnections: () => Promise.resolve({ connections: mocks.connections }), + getCatalog: () => + Promise.resolve({ success: true, connectors: mocks.catalog }), }, })); @@ -195,6 +198,7 @@ describe('NewAgent form', () => { mocks.createAgent.mockClear(); mocks.tools = null; mocks.connections = []; + mocks.catalog = []; }); const render = async () => { @@ -322,6 +326,36 @@ describe('NewAgent form', () => { ]); }); + // A teammate's connection is never in the caller's list; its tool still + // belongs with the services, named from the catalog. + it("groups a teammate's connected tool under its service, before custom", async () => { + mocks.tools = [ + { id: 'custom', name: 'api_tool', display_name: 'My API' }, + { + id: 'shared-tg', + name: 'telegram', + displayName: 'Telegram', + connection_id: 'owner-conn', + access: 'viewer', + allowed_actions: ['use', 'use_in_own'], + }, + ]; + mocks.catalog = [ + { + key: 'telegram', + name: 'Telegram', + icon: 'tool_telegram', + publisher: 'built_in', + tool_templates: ['telegram'], + }, + ]; + await render(); + const groups = Array.from( + container.querySelectorAll('[data-testid="picker"] [data-group]'), + ).map((item) => item.getAttribute('data-group')); + expect(groups).toEqual(['Telegram', 'agents.form.toolsPopup.groupCustom']); + }); + it('labels every picker with a floating label', async () => { await render(); const labels = Array.from( diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index ec96c554..c2e7977d 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -58,7 +58,8 @@ import { connectionNeedsSignIn } from '../connectors/connectorsSlice'; import SignInAgainNotice, { useSignInAgain, } from '../connectors/SignInAgainNotice'; -import type { Connection } from '../connectors/types'; +import { toolServiceOf } from '../connectors/toolService'; +import type { Connection, ConnectorDefinition } from '../connectors/types'; import AgentDetailsModal from '../modals/AgentDetailsModal'; import ShareToTeamModal from '../teams/ShareToTeamModal'; import ConfirmationModal from '../modals/ConfirmationModal'; @@ -633,7 +634,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { useEffect(() => { const getTools = async () => { - const [toolsResponse, devicesResult, connectionsResult] = + const [toolsResponse, devicesResult, connectionsResult, catalogResult] = await Promise.all([ userService.getUserTools(token), // Tolerate failures here: the picker should still render the @@ -642,12 +643,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { connectorsService .listConnections(token) .catch(() => ({ connections: [] })), + // Names a teammate's connected tool, whose connection the caller + // never sees. + connectorsService.getCatalog(token).catch(() => ({ connectors: [] })), ]); + const ownConnections = (connectionsResult?.connections ?? + []) as Connection[]; + const catalog = (catalogResult?.connectors ?? + []) as ConnectorDefinition[]; const connectionsById = new Map( - ((connectionsResult?.connections ?? []) as Connection[]).map((c) => [ - c.id, - c, - ]), + ownConnections.map((c) => [c.id, c]), ); if (!toolsResponse.ok) throw new Error('Failed to fetch tools'); const data = await toolsResponse.json(); @@ -668,24 +673,24 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { devicesById.set(d.id, { online, last_seen_at: d.last_seen_at }); }); // Group ordering: builtins -> defaults -> one group per connection - // (the service and its account) -> custom tools, via the - // MultiSelectPopover first-appearance grouping. - const connectionOf = (tool: UserToolType) => - tool.connection_id - ? connectionsById.get(tool.connection_id) - : undefined; + // (the service and its account; a teammate's, only the service) -> + // custom tools, via the MultiSelectPopover first-appearance grouping. + const serviceOf = (tool: UserToolType) => + toolServiceOf(tool, ownConnections, catalog); + const connectionOf = (tool: UserToolType) => serviceOf(tool)?.connection; const rank = (tool: UserToolType) => - tool.builtin ? 0 : tool.default ? 1 : connectionOf(tool) ? 2 : 3; + tool.builtin ? 0 : tool.default ? 1 : tool.connection_id ? 2 : 3; const groupFor = (tool: UserToolType): string => { if (tool.builtin) return t('agents.form.toolsPopup.groupBuiltin'); if (tool.default) return t('agents.form.toolsPopup.groupDefault'); - const connection = connectionOf(tool); - if (connection) + const service = serviceOf(tool); + if (service?.connection) return t('agents.form.toolsPopup.groupConnection', { - name: connection.name, - account: connection.account_label, + name: service.connection.name, + account: service.connection.account_label, interpolation: { escapeValue: false }, }); + if (service) return service.name; return t('agents.form.toolsPopup.groupCustom'); }; const tools: MultiSelectPopoverItem[] = [...visibleTools] @@ -697,11 +702,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ) .map((tool: UserToolType) => { const connection = connectionOf(tool); + const serviceIcon = serviceOf(tool)?.icon; const base: MultiSelectPopoverItem = { id: tool.id, label: getToolDisplayName(tool), - icon: connection ? ( - + icon: serviceIcon ? ( + ) : ( ), diff --git a/frontend/src/components/MessageInput.tools.test.tsx b/frontend/src/components/MessageInput.tools.test.tsx new file mode 100644 index 00000000..64ee456f --- /dev/null +++ b/frontend/src/components/MessageInput.tools.test.tsx @@ -0,0 +1,174 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +import type { MultiSelectPopoverItem } from './MultiSelectPopover'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('../upload/Upload', () => ({ default: () => null })); +vi.mock('../connectors/SignInAgainNotice', () => ({ + default: () => null, + useSignInAgain: () => ({ reconnect: vi.fn(), modals: null }), +})); + +const mocks = vi.hoisted(() => ({ + tools: [] as unknown[], + catalog: [] as unknown[], + connections: [] as unknown[], +})); + +vi.mock('../api/services/userService', () => ({ + default: { + getUserTools: () => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, tools: mocks.tools }), + }), + }, +})); +vi.mock('../api/services/connectorsService', () => ({ + default: { + getCatalog: () => + Promise.resolve({ success: true, connectors: mocks.catalog }), + listConnections: () => + Promise.resolve({ success: true, connections: mocks.connections }), + }, +})); + +// The Tools picker renders each item's group; a button opens it. +vi.mock('./message-input', async (importOriginal) => ({ + ...(await importOriginal()), + ToolsTrigger: ({ + items, + onOpenChange, + }: { + items: MultiSelectPopoverItem[]; + onOpenChange: (open: boolean) => void; + }) => ( +
+
+ ), +})); + +import connectorsReducer from '../connectors/connectorsSlice'; +import notificationsReducer from '../notifications/notificationsSlice'; +import { prefSlice } from '../preferences/preferenceSlice'; +import uploadReducer from '../upload/uploadSlice'; +import MessageInput from './MessageInput'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const makeStore = () => + configureStore({ + reducer: { + preference: prefSlice.reducer, + upload: uploadReducer, + notifications: notificationsReducer, + connectors: connectorsReducer, + }, + }); + +describe('MessageInput tools picker', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + localStorage.clear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const openPicker = async () => { + await act(async () => { + root.render( + + undefined} + loading={false} + showSourceButton={false} + showToolButton + autoFocus={false} + /> + , + ); + }); + await act(async () => { + container + .querySelector('[data-testid="open-tools"]')! + .click(); + }); + for (let i = 0; i < 4; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } + }; + + const groups = () => + Object.fromEntries( + Array.from( + container.querySelectorAll('[data-testid="tool-item"]'), + ).map((item) => [item.textContent, item.dataset.group]), + ); + + // A teammate's connection is never in the caller's list; the tool still + // sits under its service rather than with the built-in or custom tools. + it("groups a teammate's connected tool under its service", async () => { + mocks.catalog = [ + { + key: 'linear', + name: 'Linear', + icon: 'linear', + publisher: 'preset', + tool_templates: ['mcp_tool'], + mcp_url: 'https://mcp.linear.app/mcp', + }, + ]; + mocks.tools = [ + { + id: 'mem', + name: 'memory', + displayName: 'Memory', + default: true, + status: true, + config: {}, + }, + { + id: 'lin', + name: 'mcp_tool', + displayName: 'Linear', + customName: 'Team Linear', + connection_id: 'owner-conn', + status: false, + in_chat: false, + access: 'viewer', + allowed_actions: ['use', 'use_in_own'], + config: { server_url: 'https://mcp.linear.app/mcp' }, + }, + ]; + await openPicker(); + expect(groups()).toEqual({ + Memory: 'settings.tools.groupBuiltIn', + 'Team Linear': 'Linear', + }); + }); +}); diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index c7c1825e..267302c7 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -47,7 +47,9 @@ import { connectionNeedsSignIn, loadConnectors, selectConnections, + selectConnectorCatalog, } from '../connectors/connectorsSlice'; +import { toolServiceOf } from '../connectors/toolService'; import SignInAgainNotice, { useSignInAgain, } from '../connectors/SignInAgainNotice'; @@ -350,6 +352,7 @@ export default function MessageInput({ const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false); const [userTools, setUserTools] = useState([]); const connections = useSelector(selectConnections); + const catalog = useSelector(selectConnectorCatalog); const [toolsLoading, setToolsLoading] = useState(false); const [uploadModalState, setUploadModalState] = useState('INACTIVE'); @@ -1585,32 +1588,33 @@ export default function MessageInput({ // Tools from a connected service sit under that service; the rest are // built in. Groups only appear once some tool comes from a connection. - const toolConnection = (tool: UserToolType) => - tool.connection_id - ? connections.find((c) => c.id === tool.connection_id) - : undefined; - const anyConnectedTool = userTools.some((tool) => toolConnection(tool)); + // A teammate's connection is never in the caller's list, so the tool's own + // connection id decides, and the service is named from the catalog. + const toolService = (tool: UserToolType) => + toolServiceOf(tool, connections, catalog); + const anyConnectedTool = userTools.some((tool) => !!tool.connection_id); // Same groups as the agent builder: built in, one per service, then custom // tools (an API tool, an MCP server with no connection). const isCustomTool = (tool: UserToolType) => - !toolConnection(tool) && + !tool.connection_id && (tool.name === 'api_tool' || tool.name === 'mcp_tool'); const toolRank = (tool: UserToolType) => - toolConnection(tool) ? 1 : isCustomTool(tool) ? 2 : 0; + tool.connection_id ? 1 : isCustomTool(tool) ? 2 : 0; const toolItems: MultiSelectPopoverItem[] = [...userTools] .sort((a, b) => toolRank(a) - toolRank(b)) .map((tool) => { - const connection = toolConnection(tool); + const service = toolService(tool); + const connection = service?.connection; return { id: tool.id, label: tool.customName || tool.displayName, - icon: connection ? ( - + icon: service?.icon ? ( + ) : ( ), group: anyConnectedTool - ? (connection?.name ?? + ? (service?.name ?? (isCustomTool(tool) ? t('agents.form.toolsPopup.groupCustom') : t('settings.tools.groupBuiltIn')))