mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 02:14:23 +00:00
Group a teammate's connected tool under its service in the tool pickers
The caller's connections never include a teammate's, so a shared connected tool fell under Built in or Custom. The composer and agent pickers now rank on the tool's connection id and name the service from the catalog, or from the tool's own name when the catalog has no match.
This commit is contained in:
1 parent
0eb309a592
commit
ca6f840fcd
4 files changed
+248
-30
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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<string, Connection>(
|
||||
((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 ? (
|
||||
<ConnectorIcon icon={connection.icon} className="size-5" />
|
||||
icon: serviceIcon ? (
|
||||
<ConnectorIcon icon={serviceIcon} className="size-5" />
|
||||
) : (
|
||||
<ToolIcon name={tool.name} className="size-5" />
|
||||
),
|
||||
|
||||
@@ -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<typeof import('./message-input')>()),
|
||||
ToolsTrigger: ({
|
||||
items,
|
||||
onOpenChange,
|
||||
}: {
|
||||
items: MultiSelectPopoverItem[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-tools"
|
||||
onClick={() => onOpenChange(true)}
|
||||
/>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} data-testid="tool-item" data-group={item.group}>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<Provider store={makeStore()}>
|
||||
<MessageInput
|
||||
onSubmit={() => undefined}
|
||||
loading={false}
|
||||
showSourceButton={false}
|
||||
showToolButton
|
||||
autoFocus={false}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[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<HTMLElement>('[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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<UserToolType[]>([]);
|
||||
const connections = useSelector(selectConnections);
|
||||
const catalog = useSelector(selectConnectorCatalog);
|
||||
const [toolsLoading, setToolsLoading] = useState(false);
|
||||
const [uploadModalState, setUploadModalState] =
|
||||
useState<ActiveState>('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 ? (
|
||||
<ConnectorIcon icon={connection.icon} className="size-5" />
|
||||
icon: service?.icon ? (
|
||||
<ConnectorIcon icon={service.icon} className="size-5" />
|
||||
) : (
|
||||
<ToolIcon name={tool.name} className="size-5" />
|
||||
),
|
||||
group: anyConnectedTool
|
||||
? (connection?.name ??
|
||||
? (service?.name ??
|
||||
(isCustomTool(tool)
|
||||
? t('agents.form.toolsPopup.groupCustom')
|
||||
: t('settings.tools.groupBuiltIn')))
|
||||
|
||||
Reference in new issue
Block a user