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
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { humanizeKey } from '@studio/routes/agents/AgentDetailRoute/configFormat';
import { ConfigValue } from '@studio/routes/agents/AgentDetailRoute/ConfigValue';
import { render, screen } from '@studio/tests/util/render';

describe('humanizeKey', () => {
it.each([
['_type', 'Type'],
['model_name', 'Model Name'],
['llm_name', 'LLM Name'],
['base_url', 'Base URL'],
['parseAgentResponseMaxRetries', 'Parse Agent Response Max Retries'],
])('humanizes %s -> %s', (input, expected) => {
expect(humanizeKey(input)).toBe(expected);
});
});

describe('ConfigValue', () => {
it('renders a scalar as a single row', () => {
render(<ConfigValue label="verbose" value={false} />);
expect(screen.getByText('Verbose')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
});

it('joins a scalar array', () => {
render(<ConfigValue label="tool_names" value={['wiki', 'clock']} />);
expect(screen.getByText('Tool Names')).toBeInTheDocument();
expect(screen.getByText('wiki, clock')).toBeInTheDocument();
});

it('masks sensitive keys', () => {
render(<ConfigValue label="api_key" value="super-secret" />);
expect(screen.queryByText('super-secret')).not.toBeInTheDocument();
expect(screen.getByText('••••••••')).toBeInTheDocument();
});

it.each(['max_tokens', 'maxTokens'])('does not mask token-count key %s', (label) => {
render(<ConfigValue label={label} value={4096} />);
expect(screen.getByText('4096')).toBeInTheDocument();
expect(screen.queryByText('••••••••')).not.toBeInTheDocument();
});

it('recurses into nested objects', () => {
render(<ConfigValue label="llm" value={{ _type: 'openai', temperature: 0 }} />);
expect(screen.getByText('Type')).toBeInTheDocument();
expect(screen.getByText('openai')).toBeInTheDocument();
expect(screen.getByText('Temperature')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { KVPair } from '@nemo/common/src/components/KVPair';
import { Stack, Text } from '@nvidia/foundations-react-core';
import { humanizeKey } from '@studio/routes/agents/AgentDetailRoute/configFormat';
import type { FC, ReactNode } from 'react';

const SENSITIVE_KEY = /(api[_-]?key|secret|token|password|passwd|credential)/i;
const MASK = '••••••••';

/** Lowercase, separator-stripped so snake_case and camelCase keys compare equal. */
const normalizeKey = (key: string): string => key.toLowerCase().replace(/[_-]/g, '');

/** Keys that match SENSITIVE_KEY on substring but are ordinary config, not secrets. */
const NON_SENSITIVE_KEYS = new Set(
[
'max_tokens',
'max_new_tokens',
'max_input_tokens',
'max_output_tokens',
'max_completion_tokens',
'max_prompt_tokens',
'num_tokens',
'token_limit',
'context_window_tokens',
].map(normalizeKey)
);

const isSensitiveKey = (key: string): boolean =>
!NON_SENSITIVE_KEYS.has(normalizeKey(key)) && SENSITIVE_KEY.test(key);

const isScalar = (value: unknown): boolean =>
value === null || ['string', 'number', 'boolean'].includes(typeof value);

const formatScalar = (value: unknown): string => {
if (value === null || value === undefined) return '—';
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
};

interface ConfigValueProps {
label: string;
value: unknown;
}

/**
* Recursively renders an arbitrary config value as KVPair rows. Scalars (and
* scalar-only arrays) collapse to a single row; nested objects/arrays indent.
* Values under sensitive keys (api_key, token, secret, …) are masked.
*/
export const ConfigValue: FC<ConfigValueProps> = ({ label, value }) => {
const heading = humanizeKey(label);

if (isSensitiveKey(label) && value != null && value !== '') {
return <KVPair label={heading} value={MASK} />;
}

if (Array.isArray(value)) {
if (value.length === 0) return <KVPair label={heading} value="—" />;
if (value.every(isScalar)) {
return <KVPair label={heading} value={value.map(formatScalar).join(', ')} />;
}
return (
<NestedBlock heading={heading}>
{value.map((item, index) => (
<ConfigValue key={index} label={`${index + 1}`} value={item} />
))}
</NestedBlock>
);
}

if (value !== null && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return <KVPair label={heading} value="—" />;
return (
<NestedBlock heading={heading}>
{entries.map(([childKey, childValue]) => (
<ConfigValue key={childKey} label={childKey} value={childValue} />
))}
</NestedBlock>
);
}

return <KVPair label={heading} value={formatScalar(value)} />;
};

const NestedBlock: FC<{ heading: string; children: ReactNode }> = ({ heading, children }) => (
<Stack gap="1">
<Text kind="label/regular/sm" className="text-secondary">
{heading}
</Text>
<Stack gap="2" className="border-l border-base pl-3">
{children}
</Stack>
</Stack>
);

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { KVPair } from '@nemo/common/src/components/KVPair';
import { RelativeTime } from '@nemo/common/src/components/RelativeTime';
import { isDefined } from '@nemo/common/src/utils/list';
import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent';
import { Stack, Text } from '@nvidia/foundations-react-core';
import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView';
import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils';
import { ConfigValue } from '@studio/routes/agents/AgentDetailRoute/ConfigValue';
import { DetailPanel } from '@studio/routes/agents/AgentDetailRoute/overview/DetailPanel';
import type { FC } from 'react';

/** Config keys rendered by dedicated structured panels below. */
const STRUCTURED_KEYS = ['workflow', 'llms', 'functions'] as const;

interface DetailsTabProps {
workspace: string;
agentName?: string;
agent?: Agent;
}

/**
* Read-only view of the exact spec an agent is built with: identity metadata
* plus every field of its config, grouped into structured panels with an
* "Additional configuration" fallback so nothing in the spec is hidden.
*/
export const DetailsTab: FC<DetailsTabProps> = ({ workspace, agentName, agent }) => {
const config = (agent?.config ?? {}) as Record<string, unknown>;
const models = getAgentModelNames(agent?.config as AgentConfig | undefined);

const workflow = asRecord(config['workflow']);
const llms = asRecord(config['llms']);
const functions = asRecord(config['functions']);
const extraEntries = Object.entries(config).filter(
([key]) => !STRUCTURED_KEYS.includes(key as (typeof STRUCTURED_KEYS)[number])
);

return (
<Stack gap="4" className="mx-auto w-full max-w-3xl pb-6">
<DetailPanel title="Overview">
<Stack gap="2">
<KVPair label="Name" value={agent?.name ?? agentName} />
<KVPair label="Workspace" value={agent?.workspace ?? workspace} />
{isDefined(agent?.project) && agent.project && (
<KVPair label="Project" value={agent.project} />
)}
{isDefined(agent?.description) && agent.description && (
<KVPair label="Description" value={agent.description} />
)}
{models.length > 0 && <KVPair label="Model" value={models.join(', ')} />}
{isDefined(agent?.config_format) && (
<KVPair label="Config format" value={agent.config_format} />
)}
{isDefined(agent?.id) && <KVPair label="Agent ID" value={agent.id} truncate />}
{isDefined(agent?.created_at) && agent.created_at && (
<KVPair label="Created" value={<RelativeTime datetime={agent.created_at} />} />
)}
{isDefined(agent?.updated_at) && agent.updated_at && (
<KVPair label="Updated" value={<RelativeTime datetime={agent.updated_at} />} />
)}
</Stack>
</DetailPanel>

{workflow && (
<DetailPanel title="Workflow">
<ConfigEntries data={workflow} />
</DetailPanel>
)}

{llms && (
<DetailPanel title="Models">
<ConfigEntries data={llms} />
</DetailPanel>
)}

{functions && (
<DetailPanel title="Tools">
<ConfigEntries data={functions} />
</DetailPanel>
)}

{extraEntries.length > 0 && (
<DetailPanel title="Additional configuration">
<Stack gap="2">
{extraEntries.map(([key, value]) => (
<ConfigValue key={key} label={key} value={value} />
))}
</Stack>
</DetailPanel>
)}

{Object.keys(config).length === 0 && (
<DetailPanel title="Configuration">
<Text kind="body/regular/sm" className="text-secondary">
This agent has no stored configuration.
</Text>
</DetailPanel>
)}
</Stack>
);
};

const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;

const ConfigEntries: FC<{ data: Record<string, unknown> }> = ({ data }) => (
<Stack gap="2">
{Object.entries(data).map(([key, value]) => (
<ConfigValue key={key} label={key} value={value} />
))}
</Stack>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const ACRONYMS: Record<string, string> = {
llm: 'LLM',
llms: 'LLMs',
url: 'URL',
api: 'API',
id: 'ID',
uri: 'URI',
};

/** Turns a snake_case / camelCase config key into a readable label. */
export const humanizeKey = (key: string): string =>
key
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim()
.split(' ')
.filter(Boolean)
.map((word) => ACRONYMS[word.toLowerCase()] ?? word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('AgentDetailRoute', () => {
expect(screen.getByRole('tab', { name: 'Evaluations' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Logs' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Chat' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Details' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Overview' })).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Configuration' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open traces' })).toBeInTheDocument();
Expand All @@ -46,4 +47,24 @@ describe('AgentDetailRoute', () => {
expect(screen.getByRole('tab', { name: 'Chat' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('textbox', { name: /Task prompt/i })).toBeInTheDocument();
});

it('shows the agent spec on the details tab and masks secrets', async () => {
const user = userEvent.setup();
renderDetail();

await user.click(await screen.findByRole('tab', { name: 'Details' }));

expect(screen.getByRole('tab', { name: 'Details' })).toHaveAttribute('aria-selected', 'true');
// Structured panels
expect(await screen.findByText('Overview')).toBeInTheDocument();
expect(screen.getByText('Workflow')).toBeInTheDocument();
expect(screen.getByText('Models')).toBeInTheDocument();
expect(screen.getByText('Tools')).toBeInTheDocument();
// Config values surfaced from the spec
expect(screen.getByText('nat-workflow-v1')).toBeInTheDocument();
expect(screen.getByText('react_agent')).toBeInTheDocument();
// The llm api_key is masked, never shown raw
expect(screen.queryByText('not-used')).not.toBeInTheDocument();
expect(screen.getByText('••••••••')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal';
import { DeploymentsTab } from '@studio/routes/agents/AgentDetailRoute/DeploymentsTab';
import { DetailsTab } from '@studio/routes/agents/AgentDetailRoute/DetailsTab';
import { EvaluationsTab } from '@studio/routes/agents/AgentDetailRoute/EvaluationsTab';
import { SubmitEvaluationModal } from '@studio/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal';
import { getAgentMonitorRoute, getAgentsListRoute } from '@studio/routes/utils';
Expand All @@ -33,7 +34,7 @@ import { type FC, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';

const TAB_SEARCH_PARAM = 'tab';
const DETAIL_TABS = ['deployments', 'logs', 'chat', 'evaluations'] as const;
const DETAIL_TABS = ['deployments', 'logs', 'chat', 'evaluations', 'details'] as const;
const DEFAULT_TAB = 'deployments';

type AgentDetailTab = (typeof DETAIL_TABS)[number];
Expand Down Expand Up @@ -169,6 +170,7 @@ export const AgentDetailRoute: FC = () => {
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="evaluations">Evaluations</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>

<TabsContent className="min-h-0 flex-1 overflow-auto p-0 pt-6" value="evaluations">
Expand Down Expand Up @@ -216,6 +218,10 @@ export const AgentDetailRoute: FC = () => {
/>
</div>
</TabsContent>

<TabsContent className="min-h-0 flex-1 overflow-auto p-0 pt-6" value="details">
<DetailsTab workspace={workspace} agentName={agentName} agent={agent} />
</TabsContent>
</TabsRoot>
</Stack>
<SubmitEvaluationModal
Expand Down