Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
* });
* ```
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'}},
* });
* ```
*/
Expand Down
1 change: 1 addition & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
27 changes: 23 additions & 4 deletions packages/javascript/src/models/agent-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,27 @@ 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<string, unknown>;
/**
* Populated only when the agent has an inbound client.
*/
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;
Expand All @@ -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<string, unknown>;
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;
Expand All @@ -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<string, unknown>;
description?: string;
inboundAuthConfig?: AgentInboundAuthConfig[];
logoUrl?: string;
name: string;
ouId: string;
owner?: string;
type: string;
Expand All @@ -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<string, unknown>;
authFlowId?: string;
/**
Expand All @@ -107,7 +127,6 @@ export interface UpdateAgentRequest {
inboundAuthConfig?: AgentInboundAuthConfig[];
isRegistrationFlowEnabled?: boolean;
logoUrl?: string;
name?: string;
ouId?: string;
owner?: string;
registrationFlowId?: string;
Expand Down
23 changes: 23 additions & 0 deletions packages/javascript/src/utils/__tests__/getAgentName.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
26 changes: 26 additions & 0 deletions packages/javascript/src/utils/getAgentName.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>; 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;
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const AgentCreate: FC<AgentCreateProps> = ({

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});
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -158,7 +158,7 @@ const BaseAgentDetails: FC<BaseAgentDetailsProps> = ({
<AdminSection
block="agent-details"
className={className}
title={agent.name}
title={getAgentName(agent)}
description={agent.description}
preferences={preferences}
actions={
Expand All @@ -169,13 +169,13 @@ const BaseAgentDetails: FC<BaseAgentDetailsProps> = ({
actions={lifecycleActions}
collectsOperatorNote
isRunning={isRunningLifecycleAction}
name={agent.name}
name={getAgentName(agent)}
onAction={onLifecycleAction}
/>
)}
{onDelete && !agent.isReadOnly && (
<ConfirmButton
confirmText={t('admin.agents.details.delete.confirm', {name: agent.name})}
confirmText={t('admin.agents.details.delete.confirm', {name: getAgentName(agent)})}
isLoading={isDeleting}
onConfirm={onDelete}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -93,7 +93,11 @@ const BaseAgentList: FC<BaseAgentListProps> = ({
const {t} = useTranslation(preferences?.i18n);

const columns: DataTableColumn<BasicAgent>[] = [
{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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
RoleQueryKeys,
UserQueryKeys,
addGroupMembers,
getAgentName,
getAgents,
getGroupMembers,
getUsers,
Expand Down Expand Up @@ -111,7 +112,7 @@ const GroupMembers: FC<GroupMembersProps> = ({

const candidates: Partial<Record<'user' | 'agent', IdentityCandidate[]>> = 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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
RoleQueryKeys,
UserQueryKeys,
addRoleAssignments,
getAgentName,
getAgents,
getGroups,
getRoleAssignments,
Expand Down Expand Up @@ -127,7 +128,7 @@ const RoleAssignments: FC<RoleAssignmentsProps> = ({

const candidates: Partial<Record<'user' | 'group' | 'agent', IdentityCandidate[]>> = 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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(
<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 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(
<BaseAgentDetails
agent={{attributes: {name: 'Billing bot'}, id: 'ag-2', ouId: 'ou-1', type: 'bot'}}
onDelete={onDelete}
/>,
);

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<ApiFetcher>().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(<AgentCreate onCreated={onCreated} />, {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',
Expand Down Expand Up @@ -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',
},
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ export {
executeAdministrationFlow,
findAdministrationFlows,
getAccountLifecycleActions,
getAgentName,
getAgentType,
getUserType,
getUserTypes,
Expand Down
Loading
Loading