From 13644accccbde4b4e9721f07aa00ddb04b60fd27 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 28 Oct 2026 21:29:43 +0530 Subject: [PATCH] Read and write an agent's name through its attributes The ThunderID server no longer has a top-level name on agents (thunder-id/thunderid 9114cab02, "Move the agent name into the agent type schema"). An agent's name is now the `name` attribute in `attributes`, and reads made with `include=display` also return `display`, the display value the server resolves from those attributes. The SDKs still sent and read a top-level `name`, so AgentCreate registered agents without a name (the server drops the field), AgentDetails showed an empty title and asked "Delete undefined?", the AgentList name column was blank, and agent candidates in RoleAssignments and GroupMembers had blank labels. - Models: `Agent` and `BasicAgent` drop `name` and gain `display`; `BasicAgent` gains `attributes`. `CreateAgentRequest` and `UpdateAgentRequest` drop `name`; the name goes in `attributes.name`. - Add `getAgentName(agent)` to @thunderid/javascript. It returns `display`, then a non-empty `attributes.name`, then the agent's id. React and Vue re-export it. - React and Vue: AgentList, AgentDetails (title, lifecycle confirmation and delete confirmation), RoleAssignments and GroupMembers label agents with getAgentName. AgentCreate sends `attributes: {name}`; BaseAgentCreate's form and submission are unchanged. - Update the doc examples, tests and playground samples, stories and capability checks to the new shape. Signed-off-by: Brion --- .../src/api/management/agents/createAgent.ts | 2 +- .../src/api/management/agents/updateAgent.ts | 2 +- packages/javascript/src/index.ts | 1 + .../javascript/src/models/agent-resource.ts | 27 +++++- .../src/utils/__tests__/getAgentName.test.ts | 23 +++++ packages/javascript/src/utils/getAgentName.ts | 26 +++++ .../admin/AgentCreate/AgentCreate.tsx | 2 +- .../admin/AgentDetails/BaseAgentDetails.tsx | 8 +- .../admin/AgentList/BaseAgentList.tsx | 8 +- .../admin/GroupMembers/GroupMembers.tsx | 3 +- .../admin/RoleAssignments/RoleAssignments.tsx | 3 +- .../admin/__tests__/adminComponents.test.tsx | 94 ++++++++++++++++++- packages/react/src/index.ts | 1 + .../components/admin/admin-components.test.ts | 68 ++++++++++++-- .../admin/AgentCreate/AgentCreate.ts | 2 +- .../admin/AgentDetails/BaseAgentDetails.ts | 12 ++- .../admin/AgentList/BaseAgentList.ts | 8 +- .../admin/GroupMembers/GroupMembers.ts | 6 +- .../admin/RoleAssignments/RoleAssignments.ts | 6 +- packages/vue/src/index.ts | 1 + .../nextjs/src/explorer/adminStories.tsx | 18 +++- .../nextjs/src/overview/capabilities.ts | 3 +- playgrounds/nuxt/lib/adminStories.ts | 34 ++++++- playgrounds/nuxt/lib/capabilities.ts | 3 +- playgrounds/react/src/pages/capabilities.ts | 5 +- .../pages/explorer/CustomRenderWidgets.tsx | 3 +- .../react/src/pages/explorer/adminSamples.ts | 18 +++- .../react/src/pages/explorer/adminStories.tsx | 3 +- .../pages/explorer/customRenderStories.tsx | 7 +- playgrounds/vue/src/pages/capabilities.ts | 5 +- .../vue/src/pages/explorer/adminStories.ts | 13 ++- 31 files changed, 360 insertions(+), 55 deletions(-) create mode 100644 packages/javascript/src/utils/__tests__/getAgentName.test.ts create mode 100644 packages/javascript/src/utils/getAgentName.ts 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 = {