diff --git a/packages/javascript/src/api/management/agents/createAgent.ts b/packages/javascript/src/api/management/agents/createAgent.ts index e26c5018..88ac429a 100644 --- a/packages/javascript/src/api/management/agents/createAgent.ts +++ b/packages/javascript/src/api/management/agents/createAgent.ts @@ -25,7 +25,7 @@ export interface CreateAgentConfig extends ManagementRequestConfig { * ```typescript * const agent = await createAgent({ * baseUrl: 'https://localhost:8090', - * payload: {ouId, type: 'default', name: 'Nightly Sync Worker'}, + * payload: {attributes: {name: 'Nightly Sync Worker'}, ouId, type: 'default'}, * }); * ``` */ diff --git a/packages/javascript/src/api/management/agents/updateAgent.ts b/packages/javascript/src/api/management/agents/updateAgent.ts index d40a3c9a..d4e6bfa2 100644 --- a/packages/javascript/src/api/management/agents/updateAgent.ts +++ b/packages/javascript/src/api/management/agents/updateAgent.ts @@ -30,7 +30,7 @@ export interface UpdateAgentConfig extends ManagementRequestConfig { * const agent = await updateAgent({ * baseUrl: 'https://localhost:8090', * agentId: '6f1e2d3c-4b5a-4978-8a6b-5c4d3e2f1a0b', - * payload: {name: 'Renamed Agent'}, + * payload: {attributes: {name: 'Renamed Agent'}}, * }); * ``` */ diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index bcbb9aa8..7b11705b 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -871,6 +871,7 @@ export {default as toCreateApplicationRequest} from './utils/toCreateApplication export {default as toUpdateApplicationRequest} from './utils/toUpdateApplicationRequest'; export {default as toManagementErrorMessage} from './utils/toManagementErrorMessage'; export {default as resolveManagedIdentityName} from './utils/resolveManagedIdentityName'; +export {default as getAgentName} from './utils/getAgentName'; export {default as getAccountLifecycleActions} from './utils/getAccountLifecycleActions'; export {default as hasPermission} from './utils/hasPermission'; export {default as arrayBufferToBase64url} from './utils/arrayBufferToBase64url'; diff --git a/packages/javascript/src/models/agent-resource.ts b/packages/javascript/src/models/agent-resource.ts index 98faaa40..d75df750 100644 --- a/packages/javascript/src/models/agent-resource.ts +++ b/packages/javascript/src/models/agent-resource.ts @@ -26,6 +26,9 @@ export interface Agent { allowedAgentTypes?: string[]; allowedUserTypes?: string[]; assertion?: AssertionConfig; + /** + * The agent's attributes, as its agent type defines them. The name is `attributes.name`. + */ attributes?: Record; /** * Populated only when the agent has an inbound client. @@ -33,13 +36,17 @@ export interface Agent { authFlowId?: string; clientId?: string; description?: string; + /** + * The display value the server resolves from the agent's attributes, returned by reads made with + * `include=display`. The agent's name lives in `attributes.name`. + */ + display?: string; id: string; inboundAuthConfig?: AgentInboundAuthConfig[]; isReadOnly?: boolean; isRegistrationFlowEnabled?: boolean; loginConsent?: AgentLoginConsentConfig; logoUrl?: string; - name: string; ouHandle?: string; ouId: string; owner?: string; @@ -55,12 +62,20 @@ export interface Agent { * The summary of an agent returned in list responses. */ export interface BasicAgent { + /** + * The agent's attributes, as its agent type defines them. The name is `attributes.name`. + */ + attributes?: Record; clientId?: string; description?: string; + /** + * The display value the server resolves from the agent's attributes, returned by reads made with + * `include=display`. The agent's name lives in `attributes.name`. + */ + display?: string; id: string; isReadOnly?: boolean; logoUrl?: string; - name: string; ouHandle?: string; ouId: string; type: string; @@ -80,11 +95,13 @@ export interface AgentListResponse { * The payload used to create an agent. */ export interface CreateAgentRequest { + /** + * The agent's attributes, as its agent type defines them. The name goes in `attributes.name`. + */ attributes?: Record; description?: string; inboundAuthConfig?: AgentInboundAuthConfig[]; logoUrl?: string; - name: string; ouId: string; owner?: string; type: string; @@ -96,6 +113,9 @@ export interface CreateAgentRequest { export interface UpdateAgentRequest { allowedAgentTypes?: string[]; allowedUserTypes?: string[]; + /** + * The agent's attributes, as its agent type defines them. The name goes in `attributes.name`. + */ attributes?: Record; authFlowId?: string; /** @@ -107,7 +127,6 @@ export interface UpdateAgentRequest { inboundAuthConfig?: AgentInboundAuthConfig[]; isRegistrationFlowEnabled?: boolean; logoUrl?: string; - name?: string; ouId?: string; owner?: string; registrationFlowId?: string; diff --git a/packages/javascript/src/utils/__tests__/getAgentName.test.ts b/packages/javascript/src/utils/__tests__/getAgentName.test.ts new file mode 100644 index 00000000..997965be --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getAgentName.test.ts @@ -0,0 +1,23 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import getAgentName from '../getAgentName'; + +describe('getAgentName', () => { + it('should prefer the server-resolved display value', () => { + expect(getAgentName({attributes: {name: 'Sync Worker'}, display: 'Nightly Sync Worker', id: 'a1'})).toBe( + 'Nightly Sync Worker', + ); + }); + + it('should fall back to the name attribute', () => { + expect(getAgentName({attributes: {name: 'Sync Worker'}, id: 'a1'})).toBe('Sync Worker'); + }); + + it('should fall back to the identifier when the name attribute is missing, empty or not a string', () => { + expect(getAgentName({id: 'a1'})).toBe('a1'); + expect(getAgentName({attributes: {name: ''}, id: 'a1'})).toBe('a1'); + expect(getAgentName({attributes: {name: 42}, id: 'a1'})).toBe('a1'); + expect(getAgentName({display: '', id: 'a1'})).toBe('a1'); + }); +}); diff --git a/packages/javascript/src/utils/getAgentName.ts b/packages/javascript/src/utils/getAgentName.ts new file mode 100644 index 00000000..190f2050 --- /dev/null +++ b/packages/javascript/src/utils/getAgentName.ts @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Picks the label to show for an agent. An agent's name lives in its attributes as `attributes.name`, + * so this returns the server's `display` value, then the `name` attribute, then the agent's identifier. + * + * @param agent - An agent, or the summary of one from a list response. + * @returns The label to show. + * + * @example + * ```typescript + * getAgentName({attributes: {name: 'Nightly Sync Worker'}, id: 'a1'}); // 'Nightly Sync Worker' + * ``` + */ +const getAgentName = (agent: {attributes?: Record; display?: string; id: string}): string => { + if (agent.display) { + return agent.display; + } + + const name: unknown = agent.attributes?.['name']; + + return typeof name === 'string' && name.length > 0 ? name : agent.id; +}; + +export default getAgentName; diff --git a/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx index 121f8579..279d1ea2 100644 --- a/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx +++ b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx @@ -75,7 +75,7 @@ const AgentCreate: FC = ({ const handleSubmit = ({description, name, type}: AgentCreateSubmission): void => { if (resolvedOuId) { - void mutate({description, name, ouId: resolvedOuId, owner, type}); + void mutate({attributes: {name}, description, ouId: resolvedOuId, owner, type}); } }; diff --git a/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx b/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx index 13d2cbb6..5d702f2c 100644 --- a/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx +++ b/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {AccountLifecycleAction, Agent, MembershipGroup, Preferences} from '@thunderid/browser'; +import {AccountLifecycleAction, Agent, MembershipGroup, Preferences, getAgentName} from '@thunderid/browser'; import {FC, ReactElement, ReactNode} from 'react'; import useTranslation from '../../../hooks/useTranslation'; import Alert from '../../primitives/Alert/Alert'; @@ -158,7 +158,7 @@ const BaseAgentDetails: FC = ({ = ({ actions={lifecycleActions} collectsOperatorNote isRunning={isRunningLifecycleAction} - name={agent.name} + name={getAgentName(agent)} onAction={onLifecycleAction} /> )} {onDelete && !agent.isReadOnly && ( diff --git a/packages/react/src/components/admin/AgentList/BaseAgentList.tsx b/packages/react/src/components/admin/AgentList/BaseAgentList.tsx index b4daeb97..e8f32450 100644 --- a/packages/react/src/components/admin/AgentList/BaseAgentList.tsx +++ b/packages/react/src/components/admin/AgentList/BaseAgentList.tsx @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {BasicAgent, Preferences, SelectOption} from '@thunderid/browser'; +import {BasicAgent, Preferences, SelectOption, getAgentName} from '@thunderid/browser'; import {FC, ReactElement, ReactNode} from 'react'; import useTranslation from '../../../hooks/useTranslation'; import DataTable, {DataTableColumn} from '../../primitives/DataTable/DataTable'; @@ -93,7 +93,11 @@ const BaseAgentList: FC = ({ const {t} = useTranslation(preferences?.i18n); const columns: DataTableColumn[] = [ - {header: t('admin.agents.list.columns.name'), id: 'name', render: (agent: BasicAgent): ReactNode => agent.name}, + { + header: t('admin.agents.list.columns.name'), + id: 'name', + render: (agent: BasicAgent): ReactNode => getAgentName(agent), + }, {header: t('admin.agents.list.columns.type'), id: 'type', render: (agent: BasicAgent): ReactNode => agent.type}, { header: t('admin.agents.list.columns.client_id'), diff --git a/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx b/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx index 31f097ea..5e92eaa3 100644 --- a/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx +++ b/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx @@ -16,6 +16,7 @@ import { RoleQueryKeys, UserQueryKeys, addGroupMembers, + getAgentName, getAgents, getGroupMembers, getUsers, @@ -111,7 +112,7 @@ const GroupMembers: FC = ({ const candidates: Partial> = useMemo( () => ({ - agent: (agents.data?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: agent.name})), + agent: (agents.data?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: getAgentName(agent)})), user: (users.data?.users ?? []).map((user: ManagedUser) => ({ id: user.id, label: resolveManagedIdentityName(user), diff --git a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx index b4fa9f95..cd39421c 100644 --- a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx +++ b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx @@ -18,6 +18,7 @@ import { RoleQueryKeys, UserQueryKeys, addRoleAssignments, + getAgentName, getAgents, getGroups, getRoleAssignments, @@ -127,7 +128,7 @@ const RoleAssignments: FC = ({ const candidates: Partial> = useMemo( () => ({ - agent: (agents.data?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: agent.name})), + agent: (agents.data?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: getAgentName(agent)})), group: (groups.data?.groups ?? []).map((group: Group) => ({id: group.id, label: group.name})), user: (users.data?.users ?? []).map((user: ManagedUser) => ({ id: user.id, diff --git a/packages/react/src/components/admin/__tests__/adminComponents.test.tsx b/packages/react/src/components/admin/__tests__/adminComponents.test.tsx index 05366db4..1a7ce42c 100644 --- a/packages/react/src/components/admin/__tests__/adminComponents.test.tsx +++ b/packages/react/src/components/admin/__tests__/adminComponents.test.tsx @@ -9,9 +9,12 @@ import I18nProvider from '../../../contexts/I18n/I18nProvider'; import ThemeProvider from '../../../contexts/Theme/ThemeProvider'; import ThunderIDContext, {ThunderIDContextProps} from '../../../contexts/ThunderID/ThunderIDContext'; import UserContext, {UserContextProps} from '../../../contexts/User/UserContext'; +import AgentCreate from '../AgentCreate/AgentCreate'; import BaseAgentCreate from '../AgentCreate/BaseAgentCreate'; import AgentDetails from '../AgentDetails/AgentDetails'; +import BaseAgentDetails from '../AgentDetails/BaseAgentDetails'; import AgentList from '../AgentList/AgentList'; +import BaseAgentList from '../AgentList/BaseAgentList'; import ApplicationCreate from '../ApplicationCreate/ApplicationCreate'; import BaseApplicationCreate, {BaseApplicationCreateRenderProps} from '../ApplicationCreate/BaseApplicationCreate'; import ApplicationDetails from '../ApplicationDetails/ApplicationDetails'; @@ -712,7 +715,9 @@ describe('connected data loading', () => { return Promise.resolve(respond(users[0])); } if (pathname === '/agents/ag-1') { - return Promise.resolve(respond({id: 'ag-1', name: 'Deploy bot', ouId: 'ou-1', type: 'default'})); + return Promise.resolve( + respond({attributes: {name: 'Deploy bot'}, display: 'Deploy bot', id: 'ag-1', ouId: 'ou-1', type: 'default'}), + ); } if (init?.method === 'POST' && pathname === '/groups') { return Promise.resolve(respond({id: 'g-9', name: 'Support', ouId: 'ou-1'}, 201)); @@ -887,6 +892,85 @@ describe('connected data loading', () => { }); }); +describe('agent names', () => { + it('shows the display value, then the name attribute, then the identifier in the agent list', () => { + renderWithProviders( + , + ); + + const table: HTMLElement = screen.getByRole('table', {name: 'Agents'}); + + expect(within(table).getByText('Nightly sync worker')).toBeTruthy(); + expect(within(table).getByText('Billing bot')).toBeTruthy(); + expect(within(table).getByText('ag-3')).toBeTruthy(); + }); + + it('titles the agent details and asks to delete the agent by its name attribute', () => { + const onDelete: Mock = vi.fn(); + + renderWithProviders( + , + ); + + expect(screen.getByText('Billing bot')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', {name: 'Delete agent'})); + fireEvent.click( + within(screen.getByRole('group', {name: 'Delete Billing bot?'})).getByRole('button', {name: 'Confirm'}), + ); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + it('sends the name of a new agent as its name attribute', async () => { + const fetcher: FetcherMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (init?.method === 'POST') { + return Promise.resolve( + respond({attributes: {name: 'Concierge'}, id: 'ag-9', ouId: 'ou-1', type: 'assistant'}, 201), + ); + } + if (new URL(url).pathname === '/agent-types') { + return Promise.resolve( + respond({ + count: 1, + startIndex: 1, + totalResults: 1, + types: [{displayName: 'Assistant', handle: 'assistant', id: 'at-1', ouId: 'ou-1'}], + }), + ); + } + + return Promise.resolve(respond({agents: [], count: 0, startIndex: 1, totalResults: 0})); + }); + const onCreated: Mock = vi.fn(); + + renderWithProviders(, {fetcher, ouId: 'ou-1'}); + + await screen.findByRole('option', {name: 'Assistant'}); + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: 'Concierge'}}); + fireEvent.click(screen.getByRole('button', {name: 'Register agent'})); + + await waitFor(() => expect(onCreated).toHaveBeenCalledOnce()); + const creation: [string, RequestInit] | undefined = fetcher.mock.calls.find( + ([, init]: [string, RequestInit]) => init?.method === 'POST', + ); + + expect(creation?.[0]).toBe(`${baseUrl}/agents`); + expect(JSON.parse(creation?.[1].body as string)).toEqual({ + attributes: {name: 'Concierge'}, + ouId: 'ou-1', + type: 'assistant', + }); + }); +}); + describe('applications', () => { const application: Application = { description: 'Customer portal', @@ -1489,7 +1573,13 @@ describe('administration flows', () => { const fetcher: FetcherMock = createFlowServer({ configured: {agentSuspendFlow: 'suspend-agent', agentUnsuspendFlow: 'unsuspend-agent'}, resources: { - '/agents/a-1': {id: 'a-1', name: 'Billing bot', ouId: 'ou-1', status: {value: 'SUSPENDED'}, type: 'bot'}, + '/agents/a-1': { + attributes: {name: 'Billing bot'}, + id: 'a-1', + ouId: 'ou-1', + status: {value: 'SUSPENDED'}, + type: 'bot', + }, }, }); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 88fac4a3..216c2fb9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -592,6 +592,7 @@ export { executeAdministrationFlow, findAdministrationFlows, getAccountLifecycleActions, + getAgentName, getAgentType, getUserType, getUserTypes, diff --git a/packages/vue/src/__tests__/components/admin/admin-components.test.ts b/packages/vue/src/__tests__/components/admin/admin-components.test.ts index c6e952d6..222ec6fc 100644 --- a/packages/vue/src/__tests__/components/admin/admin-components.test.ts +++ b/packages/vue/src/__tests__/components/admin/admin-components.test.ts @@ -19,6 +19,7 @@ import AgentDetails from '../../../components/admin/AgentDetails/AgentDetails'; import BaseAgentDetails from '../../../components/admin/AgentDetails/BaseAgentDetails'; import type {BaseAgentDetailsRenderProps} from '../../../components/admin/AgentDetails/BaseAgentDetails'; import AgentList from '../../../components/admin/AgentList/AgentList'; +import BaseAgentList from '../../../components/admin/AgentList/BaseAgentList'; import ApplicationCreate from '../../../components/admin/ApplicationCreate/ApplicationCreate'; import BaseApplicationCreate from '../../../components/admin/ApplicationCreate/BaseApplicationCreate'; import type {BaseApplicationCreateRenderProps} from '../../../components/admin/ApplicationCreate/BaseApplicationCreate'; @@ -461,7 +462,9 @@ describe('admin components (vue)', () => { context.fetcher.mockImplementation( respondWith((url, init) => { if (init.method === 'DELETE') return null; - if (url.pathname === '/agents/a1') return {id: 'a1', name: 'Concierge', ouId: 'ou-1', type: 'assistant'}; + if (url.pathname === '/agents/a1') { + return {attributes: {name: 'Concierge'}, display: 'Concierge', id: 'a1', ouId: 'ou-1', type: 'assistant'}; + } if (url.pathname === '/agents/a1/roles') return {...page, roles: ['viewer']}; return {...page, groups: [{id: 'g1', name: 'Ops', ouId: 'ou-1'}]}; @@ -472,12 +475,12 @@ describe('admin components (vue)', () => { default: (props: BaseAgentDetailsRenderProps) => { renderProps = props; - return h('span', props.agent?.name); + return h('span', props.agent?.display); }, }); await vi.waitFor(() => { - expect(renderProps?.agent?.name).toBe('Concierge'); + expect(renderProps?.agent?.display).toBe('Concierge'); expect(renderProps?.roles).toEqual(['viewer']); expect(renderProps?.groups.map((group) => group.name)).toEqual(['Ops']); }); @@ -523,7 +526,7 @@ describe('admin components (vue)', () => { context.fetcher.mockImplementation( respondWith((_url, init) => init.method === 'POST' - ? {id: 'a1', name: 'Concierge', ouId: 'ou-1', type: 'assistant'} + ? {attributes: {name: 'Concierge'}, id: 'a1', ouId: 'ou-1', type: 'assistant'} : {...page, types: [{displayName: 'Assistant', handle: 'assistant'}]}, ), ); @@ -548,7 +551,11 @@ describe('admin components (vue)', () => { await vi.waitFor(() => expect(wrapper.emitted('created')).toHaveLength(1)); const create = context.fetcher.mock.calls.find((call) => call[1].method === 'POST')!; expect(create[0]).toBe('https://thunder.example/agents'); - expect(JSON.parse(create[1].body as string)).toMatchObject({name: 'Concierge', ouId: 'ou-1', type: 'assistant'}); + expect(JSON.parse(create[1].body as string)).toEqual({ + attributes: {name: 'Concierge'}, + ouId: 'ou-1', + type: 'assistant', + }); expect(invalidatedKeys(invalidate)).toEqual([[AgentQueryKeys.AGENTS]]); }); @@ -831,13 +838,17 @@ describe('admin components (vue)', () => { let renderProps: BaseAgentDetailsRenderProps | undefined; mountWith( BaseAgentDetails, - {agent: {id: 'a1', name: 'Concierge', ouId: 'ou-1', type: 'assistant'}, onDelete, roles: ['viewer']}, + { + agent: {display: 'Concierge', id: 'a1', ouId: 'ou-1', type: 'assistant'}, + onDelete, + roles: ['viewer'], + }, createManagementContext(), { default: (props: BaseAgentDetailsRenderProps) => { renderProps = props; - return h('span', props.agent?.name); + return h('span', props.agent?.display); }, }, ); @@ -889,6 +900,41 @@ describe('admin components (vue)', () => { }); }); + describe('agent names', () => { + it('shows the display value, then the name attribute, then the identifier in the agent list', () => { + const {wrapper} = mountWith(BaseAgentList, { + agents: [ + {attributes: {name: 'Sync worker'}, display: 'Nightly sync worker', id: 'ag-1', ouId: 'ou-1', type: 'bot'}, + {attributes: {name: 'Billing bot'}, id: 'ag-2', ouId: 'ou-1', type: 'bot'}, + {id: 'ag-3', ouId: 'ou-1', type: 'bot'}, + ], + }); + + const names: string[] = wrapper.findAll('tbody tr').map((row: DOMWrapper) => row.find('td').text()); + expect(names).toEqual(['Nightly sync worker', 'Billing bot', 'ag-3']); + }); + + it('titles the agent details and asks to delete the agent by its name attribute', async () => { + const onDelete = vi.fn(); + const {wrapper} = mountWith(BaseAgentDetails, { + agent: {attributes: {name: 'Billing bot'}, id: 'ag-2', ouId: 'ou-1', type: 'bot'}, + onDelete, + }); + + expect(wrapper.text()).toContain('Billing bot'); + const deleteButton = wrapper + .findAll('button') + .find( + (button: DOMWrapper) => button.text() === translate('admin.agents.details.delete.action'), + ); + await deleteButton!.trigger('click'); + + expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe( + translate('admin.agents.details.delete.confirm', {name: 'Billing bot'}), + ); + }); + }); + describe('search and filters', () => { const lastUrl = (context: ReturnType): URL => new URL(context.fetcher.mock.calls[context.fetcher.mock.calls.length - 1][0]); @@ -1563,7 +1609,13 @@ describe('admin components (vue)', () => { const context = createFlowContext({ configured: {agentSuspendFlow: 'suspend-agent', agentUnsuspendFlow: 'unsuspend-agent'}, resources: { - '/agents/a-1': {id: 'a-1', name: 'Billing bot', ouId: 'ou-1', status: {value: 'SUSPENDED'}, type: 'bot'}, + '/agents/a-1': { + attributes: {name: 'Billing bot'}, + id: 'a-1', + ouId: 'ou-1', + status: {value: 'SUSPENDED'}, + type: 'bot', + }, }, }); const {wrapper} = mountWith(AgentDetails, {agentId: 'a-1', lifecycleActions: true}, context); diff --git a/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts b/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts index 22bf3b58..2df91502 100644 --- a/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts +++ b/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts @@ -64,7 +64,7 @@ const AgentCreate: Component = defineComponent({ const handleSubmit = ({description, name, type}: AgentCreateSubmission): void => { if (resolvedOuId.value) { - void creation.mutate({description, name, ouId: resolvedOuId.value, owner: props.owner, type}); + void creation.mutate({attributes: {name}, description, ouId: resolvedOuId.value, owner: props.owner, type}); } }; diff --git a/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts b/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts index 176c71e4..6442d6f8 100644 --- a/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts +++ b/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts @@ -6,6 +6,7 @@ import { type Agent, type MembershipGroup, type Preferences, + getAgentName, withVendorCSSClassPrefix, } from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; @@ -172,7 +173,12 @@ const BaseAgentDetails: Component = defineComponent({ return h( AdminSection, - {block: 'agent-details', className: props.className, description: agent.description, title: agent.name}, + { + block: 'agent-details', + className: props.className, + description: agent.description, + title: getAgentName(agent), + }, { actions: (): (VNode | VNode[] | null | undefined)[] => [ slots['actions']?.(), @@ -181,7 +187,7 @@ const BaseAgentDetails: Component = defineComponent({ actions: props.lifecycleActions, collectsOperatorNote: true, isRunning: props.isRunningLifecycleAction, - name: agent.name, + name: getAgentName(agent), onAction: props.onLifecycleAction, t, }) @@ -190,7 +196,7 @@ const BaseAgentDetails: Component = defineComponent({ ? h( ConfirmButton, { - confirmText: t('admin.agents.details.delete.confirm', {name: agent.name}), + confirmText: t('admin.agents.details.delete.confirm', {name: getAgentName(agent)}), isLoading: props.isDeleting, onConfirm: props.onDelete, t, diff --git a/packages/vue/src/components/admin/AgentList/BaseAgentList.ts b/packages/vue/src/components/admin/AgentList/BaseAgentList.ts index c759072e..d93c6bf6 100644 --- a/packages/vue/src/components/admin/AgentList/BaseAgentList.ts +++ b/packages/vue/src/components/admin/AgentList/BaseAgentList.ts @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {type BasicAgent} from '@thunderid/browser'; +import {type BasicAgent, getAgentName} from '@thunderid/browser'; import {type Component, type VNodeChild, h} from 'vue'; import createAdminList from '../shared/createAdminList'; import type {AdminTranslate} from '../shared/useAdminTranslation'; @@ -18,7 +18,11 @@ import type {AdminTranslate} from '../shared/useAdminTranslation'; const BaseAgentList: Component = createAdminList({ block: 'agent-list', columns: (t: AdminTranslate) => [ - {header: t('admin.agents.list.columns.name'), id: 'name', render: (agent: BasicAgent): VNodeChild => agent.name}, + { + header: t('admin.agents.list.columns.name'), + id: 'name', + render: (agent: BasicAgent): VNodeChild => getAgentName(agent), + }, {header: t('admin.agents.list.columns.type'), id: 'type', render: (agent: BasicAgent): VNodeChild => agent.type}, { header: t('admin.agents.list.columns.client_id'), diff --git a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts index b23a6609..69b2e36f 100644 --- a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts +++ b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts @@ -7,6 +7,7 @@ import { RoleQueryKeys, UserQueryKeys, addGroupMembers, + getAgentName, getAgents, getGroupMembers, getUsers, @@ -126,7 +127,10 @@ const GroupMembers: Component = defineComponent({ return (): VNode => { const candidates: Partial> = { - agent: (agents.data.value?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: agent.name})), + agent: (agents.data.value?.agents ?? []).map((agent: BasicAgent) => ({ + id: agent.id, + label: getAgentName(agent), + })), user: (users.data.value?.users ?? []).map((user: ManagedUser) => ({ id: user.id, label: resolveManagedIdentityName(user), diff --git a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts index 4df7f19d..793bd8e9 100644 --- a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts +++ b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts @@ -7,6 +7,7 @@ import { RoleQueryKeys, UserQueryKeys, addRoleAssignments, + getAgentName, getAgents, getGroups, getRoleAssignments, @@ -140,7 +141,10 @@ const RoleAssignments: Component = defineComponent({ return (): VNode => { const candidates: Partial> = { - agent: (agents.data.value?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: agent.name})), + agent: (agents.data.value?.agents ?? []).map((agent: BasicAgent) => ({ + id: agent.id, + label: getAgentName(agent), + })), group: (groups.data.value?.groups ?? []).map((group: Group) => ({id: group.id, label: group.name})), user: (users.data.value?.users ?? []).map((user: ManagedUser) => ({ id: user.id, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 71dcfe8f..501c258c 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -393,6 +393,7 @@ export { executeAdministrationFlow, findAdministrationFlows, getAccountLifecycleActions, + getAgentName, getAgentType, getUserType, getUserTypes, diff --git a/playgrounds/nextjs/src/explorer/adminStories.tsx b/playgrounds/nextjs/src/explorer/adminStories.tsx index 1b944843..f43b13e8 100644 --- a/playgrounds/nextjs/src/explorer/adminStories.tsx +++ b/playgrounds/nextjs/src/explorer/adminStories.tsx @@ -101,8 +101,22 @@ const SAMPLE_ASSIGNMENTS: RoleAssignment[] = [ ]; const SAMPLE_AGENTS: BasicAgent[] = [ - {description: 'Routes support tickets', id: 'agt_triage', name: 'Triage bot', ouId: OU, type: 'assistant'}, - {description: 'Reconciles invoices', id: 'agt_ledger', name: 'Ledger', ouId: OU, type: 'worker'}, + { + attributes: {name: 'Triage bot'}, + description: 'Routes support tickets', + display: 'Triage bot', + id: 'agt_triage', + ouId: OU, + type: 'assistant', + }, + { + attributes: {name: 'Ledger'}, + description: 'Reconciles invoices', + display: 'Ledger', + id: 'agt_ledger', + ouId: OU, + type: 'worker', + }, ]; const SAMPLE_APPLICATIONS: BasicApplication[] = [ diff --git a/playgrounds/nextjs/src/overview/capabilities.ts b/playgrounds/nextjs/src/overview/capabilities.ts index 57f0f53c..8f922939 100644 --- a/playgrounds/nextjs/src/overview/capabilities.ts +++ b/playgrounds/nextjs/src/overview/capabilities.ts @@ -11,6 +11,7 @@ import { deleteRole, EmbeddedFlowType, getAgentGroups, + getAgentName, getAgentRoles, getAgents, getAgentTypes, @@ -780,7 +781,7 @@ export const CAPABILITIES: Capability[] = [ const groups = await track('getAgentGroups', () => getAgentGroups({...config, agentId: agent.id})); const roles = await track('getAgentRoles', () => getAgentRoles({...config, agentId: agent.id})); - return pass(`"${agent.name}": ${groups.totalResults} groups, roles [${roles.roles.join(', ')}]`); + return pass(`"${getAgentName(agent)}": ${groups.totalResults} groups, roles [${roles.roles.join(', ')}]`); }), section: 'Management', }, diff --git a/playgrounds/nuxt/lib/adminStories.ts b/playgrounds/nuxt/lib/adminStories.ts index 10b4f0c3..02bcc43d 100644 --- a/playgrounds/nuxt/lib/adminStories.ts +++ b/playgrounds/nuxt/lib/adminStories.ts @@ -16,7 +16,14 @@ import type { RoleResourcePermissions, RoleSummary, } from '@thunderid/browser'; -import {Attribution, Button, DataTable, type DataTableColumn, type IdentityCandidate} from '@thunderid/vue'; +import { + Attribution, + Button, + DataTable, + type DataTableColumn, + getAgentName, + type IdentityCandidate, +} from '@thunderid/vue'; import {defineComponent, h, ref, type Component, type PropType, type Ref, type VNode} from 'vue'; import {tag} from './storyCode'; import type {Action, Args, ComponentDef, PropControl, Snippet} from './storyTypes'; @@ -145,15 +152,32 @@ const SAMPLE_ROLES: RoleSummary[] = [ const SAMPLE_AGENTS: BasicAgent[] = [ { + attributes: {name: 'Travel concierge'}, clientId: 'agt_concierge', + display: 'Travel concierge', id: 'agt_1', - name: 'Travel concierge', ouHandle: 'acme', ouId: 'ou_acme', type: 'assistant', }, - {clientId: 'agt_triage', id: 'agt_2', name: 'Ticket triage', ouHandle: 'acme', ouId: 'ou_acme', type: 'worker'}, - {clientId: 'agt_reports', id: 'agt_3', name: 'Report builder', ouHandle: 'acme', ouId: 'ou_acme', type: 'worker'}, + { + attributes: {name: 'Ticket triage'}, + clientId: 'agt_triage', + display: 'Ticket triage', + id: 'agt_2', + ouHandle: 'acme', + ouId: 'ou_acme', + type: 'worker', + }, + { + attributes: {name: 'Report builder'}, + clientId: 'agt_reports', + display: 'Report builder', + id: 'agt_3', + ouHandle: 'acme', + ouId: 'ou_acme', + type: 'worker', + }, ]; const SAMPLE_APPLICATIONS: BasicApplication[] = [ @@ -221,7 +245,7 @@ const USER_CANDIDATES: IdentityCandidate[] = SAMPLE_USERS.map((user: ManagedUser const GROUP_CANDIDATES: IdentityCandidate[] = SAMPLE_GROUPS.map((group: Group) => ({id: group.id, label: group.name})); const AGENT_CANDIDATES: IdentityCandidate[] = SAMPLE_AGENTS.map((agent: BasicAgent) => ({ id: agent.id, - label: agent.name, + label: getAgentName(agent), })); const USER_TYPES: {label: string; value: string}[] = [ diff --git a/playgrounds/nuxt/lib/capabilities.ts b/playgrounds/nuxt/lib/capabilities.ts index 9b14cd81..92a562ac 100644 --- a/playgrounds/nuxt/lib/capabilities.ts +++ b/playgrounds/nuxt/lib/capabilities.ts @@ -3,6 +3,7 @@ import { getAgentGroups, + getAgentName, getAgentRoles, getAgents, getAgentTypes, @@ -818,7 +819,7 @@ export const CAPABILITIES: Capability[] = [ getAgentRoles({...config, agentId: agent.id}), ); - return pass(`"${agent.name}": ${groups.totalResults} groups, ${roles.totalResults} roles`); + return pass(`"${getAgentName(agent)}": ${groups.totalResults} groups, ${roles.totalResults} roles`); }), section: 'Management', }, diff --git a/playgrounds/react/src/pages/capabilities.ts b/playgrounds/react/src/pages/capabilities.ts index d1538e69..294014ec 100644 --- a/playgrounds/react/src/pages/capabilities.ts +++ b/playgrounds/react/src/pages/capabilities.ts @@ -5,6 +5,7 @@ import { ApiFetcher, EmbeddedFlowType, getAgentGroups, + getAgentName, getAgentRoles, getAgents, getAgentTypes, @@ -721,7 +722,9 @@ export const CAPABILITIES: Capability[] = [ getAgentRoles({...config, agentId: agent.id}), ); - return pass(`"${agent.name}": ${plural(groups.totalResults, 'group')}, ${plural(roles.totalResults, 'role')}`); + return pass( + `"${getAgentName(agent)}": ${plural(groups.totalResults, 'group')}, ${plural(roles.totalResults, 'role')}`, + ); }, section: 'Management', }, diff --git a/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx b/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx index c84b16cc..7ef0f0a8 100644 --- a/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx +++ b/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx @@ -31,6 +31,7 @@ import { type BaseUserCreateRenderProps, type BaseUserDetailsRenderProps, type DataTableColumn, + getAgentName, useTheme, } from '@thunderid/react'; import {ReactElement, ReactNode, useMemo, useState} from 'react'; @@ -395,7 +396,7 @@ export const UserCard = (details: BaseUserDetailsRenderProps): ReactElement => ( /** The agent details' render props drawn as an MUI card. */ export const AgentCard = (details: BaseAgentDetailsRenderProps): ReactElement => ( = { }; export const CANDIDATES = { - agent: SAMPLE_AGENTS.map((agent: BasicAgent) => ({id: agent.id, label: agent.name})), + agent: SAMPLE_AGENTS.map((agent: BasicAgent) => ({id: agent.id, label: getAgentName(agent)})), group: SAMPLE_GROUPS.map((group: Group) => ({id: group.id, label: group.name})), user: SAMPLE_USERS.map((user: ManagedUser) => ({id: user.id, label: user.display ?? user.id})), }; diff --git a/playgrounds/react/src/pages/explorer/adminStories.tsx b/playgrounds/react/src/pages/explorer/adminStories.tsx index 109626cb..2264a54a 100644 --- a/playgrounds/react/src/pages/explorer/adminStories.tsx +++ b/playgrounds/react/src/pages/explorer/adminStories.tsx @@ -47,6 +47,7 @@ import { UserDetails, UserList, getAccountLifecycleActions, + getAgentName, } from '@thunderid/react'; import {ReactElement} from 'react'; import { @@ -943,7 +944,7 @@ const STORIES: ComponentDef[] = [ action={action} attributes={[{label: 'Name', value: 'name'}]} rows={SAMPLE_AGENTS} - valueOf={(agent: BasicAgent, attribute: string) => (attribute === 'name' ? agent.name : undefined)} + valueOf={(agent: BasicAgent, attribute: string) => (attribute === 'name' ? getAgentName(agent) : undefined)} render={({onSearch, rows: matching, search, searchAttributes}) => ( key={JSON.stringify(search)} diff --git a/playgrounds/react/src/pages/explorer/customRenderStories.tsx b/playgrounds/react/src/pages/explorer/customRenderStories.tsx index e701c81a..2688abfa 100644 --- a/playgrounds/react/src/pages/explorer/customRenderStories.tsx +++ b/playgrounds/react/src/pages/explorer/customRenderStories.tsx @@ -25,6 +25,7 @@ import { type Group, type ManagedUser, type RoleSummary, + getAgentName, } from '@thunderid/react'; import {ReactElement, ReactNode} from 'react'; import { @@ -145,11 +146,11 @@ const identitySnippet = (name: string, idProp: string, rows: string, remove: str const CODE: Record Snippet> = { agentcreate: () => formSnippet('AgentCreate', ['name', 'type', 'description']), agentdetails: () => ({ - imports: ['AgentDetails'], + imports: ['AgentDetails', 'getAgentName'], jsx: ` {({agent, groups, roles, onDelete}) => ( - + {groups.map((group) => )} {roles.map((role) => )} @@ -396,7 +397,7 @@ const RENDER: Record ReactNode> = { ), { attributes: [{label: 'Name', value: 'name'}], - valueOf: (agent: BasicAgent, attribute: string) => (attribute === 'name' ? agent.name : undefined), + valueOf: (agent: BasicAgent, attribute: string) => (attribute === 'name' ? getAgentName(agent) : undefined), }, ), applicationcreate: (_args: Args, action: Action) => ( diff --git a/playgrounds/vue/src/pages/capabilities.ts b/playgrounds/vue/src/pages/capabilities.ts index b84931dd..b2623020 100644 --- a/playgrounds/vue/src/pages/capabilities.ts +++ b/playgrounds/vue/src/pages/capabilities.ts @@ -19,6 +19,7 @@ import { createThunderIDGuard, FlowMetaContextValue, getAgentGroups, + getAgentName, getAgentRoles, getAgents, getAgentTypes, @@ -754,7 +755,9 @@ export const CAPABILITIES: Capability[] = [ getAgentRoles({...config, agentId: agent.id}), ); - return pass(`"${agent.name}": ${plural(groups.totalResults, 'group')}, roles [${roles.roles.join(', ')}]`); + return pass( + `"${getAgentName(agent)}": ${plural(groups.totalResults, 'group')}, roles [${roles.roles.join(', ')}]`, + ); }, section: 'Management', }, diff --git a/playgrounds/vue/src/pages/explorer/adminStories.ts b/playgrounds/vue/src/pages/explorer/adminStories.ts index 8e681b74..6bbc1e6c 100644 --- a/playgrounds/vue/src/pages/explorer/adminStories.ts +++ b/playgrounds/vue/src/pages/explorer/adminStories.ts @@ -123,14 +123,23 @@ const SAMPLE_ROLES: RoleSummary[] = [ const SAMPLE_AGENTS: BasicAgent[] = [ { + attributes: {name: 'Travel concierge'}, clientId: 'concierge-01', + display: 'Travel concierge', id: 'agt_concierge', - name: 'Travel concierge', ouHandle: 'acme', ouId: OU, type: 'assistant', }, - {clientId: 'billing-02', id: 'agt_billing', name: 'Billing assistant', ouHandle: 'acme', ouId: OU, type: 'worker'}, + { + attributes: {name: 'Billing assistant'}, + clientId: 'billing-02', + display: 'Billing assistant', + id: 'agt_billing', + ouHandle: 'acme', + ouId: OU, + type: 'worker', + }, ]; const SAMPLE_AGENT: Agent = {