diff --git a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx index f80b72de4d..d4a7680cd9 100644 --- a/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/AgentsDataView/index.tsx @@ -25,7 +25,7 @@ import { } from '@nemo/sdk/generated/agents/api'; import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; -import { Button, Divider, Flex, Text } from '@nvidia/foundations-react-core'; +import { Button, Text } from '@nvidia/foundations-react-core'; import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { DocumentationButton } from '@studio/components/DocumentationButton'; @@ -34,7 +34,7 @@ import { LINK_DOCS_STUDIO } from '@studio/constants/links'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getModelCompareRoute } from '@studio/routes/utils'; import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; -import { HatGlasses, Trash, X } from 'lucide-react'; +import { HatGlasses, Trash } from 'lucide-react'; import { ComponentProps, FC, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; @@ -115,6 +115,15 @@ export const AgentsTable: FC = ({ }, }); + // `keepPreviousData` keeps the previous workspace's rows on screen after a switch, so a + // selection or a pending delete made there would still resolve — and delete by name + // against the new workspace. Drop both as soon as the workspace changes. + const clearRowSelection = dataViewState.rowSelection.set; + useEffect(() => { + clearRowSelection({}); + setDeleteState(null); + }, [workspace, clearRowSelection]); + const page = dataViewState.pagination.state.pageIndex + 1; const pageSize = dataViewState.pagination.state.pageSize; const sortParam = getSortParamWithWhitelist( @@ -177,12 +186,6 @@ export const AgentsTable: FC = ({ }); }, [agentsData, deploymentsData]); - const rowSelection = dataViewState.rowSelection.state; - const selectedAgents = useMemo( - () => tableData.filter((row) => rowSelection[row.id]), - [tableData, rowSelection] - ); - const deleteAgentMutation = useAgentsDeleteAgent(); const deleteDeploymentMutation = useAgentsDeleteDeployment(); @@ -321,32 +324,18 @@ export const AgentsTable: FC = ({ return ( <> - {selectedAgents.length > 0 && ( - - - {selectedAgents.length} {selectedAgents.length === 1 ? 'row' : 'rows'} selected - - - - - - - - )} ( + + )} onRowClick={(row: AgentTableRow) => { onAgentRowClick?.(row); }} diff --git a/web/packages/studio/src/components/dataViews/EvalComparisonTable/ComparisonDeltaCell.tsx b/web/packages/studio/src/components/dataViews/EvalComparisonTable/ComparisonDeltaCell.tsx index a4840cbe3e..b52c5dcf0f 100644 --- a/web/packages/studio/src/components/dataViews/EvalComparisonTable/ComparisonDeltaCell.tsx +++ b/web/packages/studio/src/components/dataViews/EvalComparisonTable/ComparisonDeltaCell.tsx @@ -3,7 +3,7 @@ import { Badge, Flex, Text } from '@nvidia/foundations-react-core'; import type { ComparisonMetricDelta } from '@studio/components/dataViews/EvalComparisonTable/types'; -import { formatScore } from '@studio/routes/agents/AgentEvaluationsRoute/evalScores'; +import { formatScore } from '@studio/components/evaluation/utils'; import { Equal, Minus, Plus } from 'lucide-react'; import type { FC } from 'react'; diff --git a/web/packages/studio/src/components/dataViews/EvalComparisonTable/EvalComparisonTable.tsx b/web/packages/studio/src/components/dataViews/EvalComparisonTable/EvalComparisonTable.tsx index eff9031560..b9a6eec24f 100644 --- a/web/packages/studio/src/components/dataViews/EvalComparisonTable/EvalComparisonTable.tsx +++ b/web/packages/studio/src/components/dataViews/EvalComparisonTable/EvalComparisonTable.tsx @@ -17,7 +17,7 @@ import { metricNamesForComparisons, scoreForMetric, } from '@studio/components/dataViews/EvalComparisonTable/utils'; -import { formatScore } from '@studio/routes/agents/AgentEvaluationsRoute/evalScores'; +import { formatScore } from '@studio/components/evaluation/utils'; import { useMemo, type ComponentProps, type FC } from 'react'; const METRIC_COLUMN_ID = 'metric'; diff --git a/web/packages/studio/src/constants/sampleAgents.test.ts b/web/packages/studio/src/constants/sampleAgents.test.ts deleted file mode 100644 index 27c410f921..0000000000 --- a/web/packages/studio/src/constants/sampleAgents.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { - EVALUATION_SAMPLE_AGENTS, - evaluationSampleAgentKeyForAgentName, - isSampleAgentName, - SAMPLE_AGENTS, - sampleAgentKeyForAgentName, -} from '@studio/constants/sampleAgents'; - -describe('sampleAgentKeyForAgentName', () => { - it('matches a generated example agent name to its key', () => { - expect(sampleAgentKeyForAgentName('email-phishing-demo-agent-9lhh53')).toBe( - 'email_phishing_analyzer' - ); - expect(sampleAgentKeyForAgentName('calculator-demo-agent-abc123')).toBe('calculator'); - }); - - it('returns undefined for non-example agents and empty input', () => { - expect(sampleAgentKeyForAgentName('my-custom-agent')).toBeUndefined(); - expect(sampleAgentKeyForAgentName(undefined)).toBeUndefined(); - expect(sampleAgentKeyForAgentName('')).toBeUndefined(); - }); - - it('requires the prefix separator (no partial-token match)', () => { - // 'calculator-demo-agentx-...' is not a real 'calculator-demo-agent-' name. - expect(sampleAgentKeyForAgentName('calculator-demo-agentxyz')).toBeUndefined(); - }); - - it('picks the longest matching prefix when one is a substring of another', () => { - const registry = [ - { namePrefix: 'test', key: 'short' }, - { namePrefix: 'test-agent', key: 'long' }, - ]; - const match = (name: string) => - registry - .filter((a) => name.startsWith(`${a.namePrefix}-`)) - .sort((a, b) => b.namePrefix.length - a.namePrefix.length)[0]?.key; - expect(match('test-agent-abc123')).toBe('long'); - expect(match('test-abc123')).toBe('short'); - }); - - it('every registry prefix resolves to its own key', () => { - for (const agent of SAMPLE_AGENTS) { - expect(sampleAgentKeyForAgentName(`${agent.namePrefix}-zzzz99`)).toBe(agent.key); - } - }); -}); - -describe('isSampleAgentName', () => { - it('agrees with sampleAgentKeyForAgentName (same boundary rule)', () => { - const names = [ - 'email-phishing-demo-agent-9lhh53', - 'calculator-demo-agent-abc123', - 'calculator-demo-agentxyz', // partial token — no separator - 'my-custom-agent', - '', - ]; - for (const name of names) { - expect(isSampleAgentName(name)).toBe(sampleAgentKeyForAgentName(name) !== undefined); - } - }); - - it('requires the prefix separator', () => { - expect(isSampleAgentName('calculator-demo-agent-abc123')).toBe(true); - expect(isSampleAgentName('calculator-demo-agentxyz')).toBe(false); - }); -}); - -describe('evaluation samples', () => { - it('keeps creation-only samples out of the evaluation picker', () => { - expect(SAMPLE_AGENTS.some((agent) => agent.key === 'calculator')).toBe(true); - expect(EVALUATION_SAMPLE_AGENTS.map((agent) => agent.key)).toEqual([ - 'calculator', - 'email_phishing_analyzer', - ]); - expect(evaluationSampleAgentKeyForAgentName('calculator-demo-agent-abc123')).toBe('calculator'); - expect(evaluationSampleAgentKeyForAgentName('email-phishing-demo-agent-abc123')).toBe( - 'email_phishing_analyzer' - ); - }); -}); diff --git a/web/packages/studio/src/constants/sampleAgents.ts b/web/packages/studio/src/constants/sampleAgents.ts index e898afbad4..053262accd 100644 --- a/web/packages/studio/src/constants/sampleAgents.ts +++ b/web/packages/studio/src/constants/sampleAgents.ts @@ -5,27 +5,27 @@ import { z } from 'zod'; // Registry of canned example agents. Each entry references curated static assets // under public/sample-agents// by path (fetched on demand, never bundled) — -// mirroring src/constants/sampleDatasets.ts. Used by both the Create Example Agent -// modal (fetch + parse agent.yml, inject model, POST). Samples with an -// evalConfigPath also appear in the Run Evaluation modal. +// mirroring src/constants/sampleDatasets.ts. Used by the Create Example Agent +// modal (fetch + parse agent.yml, inject model, POST). +// +// Eval configs are a SEPARATE registry (EVAL_CONFIG_SAMPLES) on purpose: either +// paradigm can target any agent, so a config is not owned by an agent. // // INVARIANT: an entry whose agent.yml uses a custom NAT `_type` requires that // tool's Python package to be installed in the deploy venv, or the deployment // fails at startup. Current mappings: // _type: calculator -> plugins/nemo-agents/examples/calculator-agent // _type: email_phishing_analyzer -> plugins/nemo-agents/examples/email-phishing-analyzer +// _type: analyze_email -> plugins/nemo-agents/examples/email-security-analyst +// _type: extract_iocs -> plugins/nemo-agents/examples/email-security-analyst export interface SampleAgent { - /** Stable key; also the dropdown value and label. */ key: string; - label: string; + displayName: string; description: string; /** Prefix for generated agent names; drives onboarding detection. */ namePrefix: string; /** Public path to the NAT workflow config (parsed + model-injected at create). */ agentConfigPath: string; - /** Public path to a reusable nemo-evaluator eval-config.json. Samples without - * one remain available for agent creation but not evaluation seeding. */ - evalConfigPath?: string; /** Config format identifier sent to the create API. Defaults to * `nat-workflow-v1` server-side when omitted; set to `nemo-agents-spec-v1` * for Fabric-backed samples so the API validates them as Fabric, not NAT. */ @@ -34,25 +34,15 @@ export interface SampleAgent { export const SAMPLE_AGENTS: SampleAgent[] = [ { - key: 'calculator', - label: 'calculator', - description: 'A ReAct agent with a calculator and datetime tool.', - namePrefix: 'calculator-demo-agent', - agentConfigPath: 'sample-agents/calculator/agent.yml', - evalConfigPath: 'sample-agents/calculator/eval-config.json', - }, - { - key: 'email_phishing_analyzer', - label: 'email_phishing_analyzer', - description: 'A ReAct agent that inspects an email body for phishing signals.', - namePrefix: 'email-phishing-demo-agent', - agentConfigPath: 'sample-agents/email-phishing-analyzer/agent.yml', - evalConfigPath: 'sample-agents/email-phishing-analyzer/eval-config.json', + key: 'email_security_analyst', + displayName: 'Email Security Analyst', + description: + 'An analyst-facing email security assistant: select one or more messages, optionally ask a question, and it routes to the capability that answers it.', + namePrefix: 'email-security-analyst', + agentConfigPath: 'sample-agents/email-security-analyst/agent.yml', }, ]; -// Eval configs are a SEPARATE registry (EVAL_CONFIG_SAMPLES) on purpose: either -// paradigm can target any agent, so a config is not owned by an agent. export interface EvalConfigSample { key: string; displayName: string; @@ -92,27 +82,11 @@ export const DEFAULT_EVAL_CONFIG_KEY = EVAL_CONFIG_SAMPLES[0].key; export const getEvalConfigSample = (key: string): EvalConfigSample => EVAL_CONFIG_SAMPLES.find((sample) => sample.key === key) ?? EVAL_CONFIG_SAMPLES[0]; -export type EvaluationSampleAgent = SampleAgent & { evalConfigPath: string }; - -export const EVALUATION_SAMPLE_AGENTS = SAMPLE_AGENTS.filter( - (agent): agent is EvaluationSampleAgent => typeof agent.evalConfigPath === 'string' -); - export const DEFAULT_SAMPLE_AGENT_KEY = SAMPLE_AGENTS[0].key; export const getSampleAgent = (key: string): SampleAgent => SAMPLE_AGENTS.find((agent) => agent.key === key) ?? SAMPLE_AGENTS[0]; -export const getEvaluationSampleAgent = (key: string): EvaluationSampleAgent => - EVALUATION_SAMPLE_AGENTS.find((agent) => agent.key === key) ?? EVALUATION_SAMPLE_AGENTS[0]; - -export const evaluationSampleAgentKeyForAgentName = ( - name: string | undefined -): string | undefined => { - const key = sampleAgentKeyForAgentName(name); - return EVALUATION_SAMPLE_AGENTS.some((agent) => agent.key === key) ? key : undefined; -}; - export const buildSampleAgentName = (namePrefix: string): string => `${namePrefix}-${Math.random().toString(36).slice(2, 8)}`; @@ -122,8 +96,7 @@ export const isSampleAgentName = (name: string): boolean => /** * Infer which sample-agent example a deployed agent came from by matching its * generated name (`${namePrefix}-`). Returns the example key, or - * undefined for agents not created from an example. Used to auto-select the - * matching eval config. + * undefined for agents not created from an example. * * Robustness: requires the `${namePrefix}-` separator (so a prefix only matches * a real name boundary, not a partial token) and picks the LONGEST matching diff --git a/web/packages/studio/src/mocks/handlers.ts b/web/packages/studio/src/mocks/handlers.ts index 9d3833565d..22c4d7d776 100644 --- a/web/packages/studio/src/mocks/handlers.ts +++ b/web/packages/studio/src/mocks/handlers.ts @@ -17,7 +17,6 @@ import { evaluatorHandlers } from '@studio/mocks/handlers/evaluator'; import { filesetsHandlers } from '@studio/mocks/handlers/filesets'; import { guardrailsHandlers } from '@studio/mocks/handlers/guardrails'; import { modelsHandlers } from '@studio/mocks/handlers/models'; -import { sampleAgentsHandlers } from '@studio/mocks/handlers/sampleAgents'; import { sampleDatasetsHandlers } from '@studio/mocks/handlers/sampleDatasets'; import { secretsHandlers } from '@studio/mocks/handlers/secrets'; import { workspacesHandlers } from '@studio/mocks/handlers/workspaces'; @@ -73,7 +72,6 @@ export interface HypermodelParams { * but tests can override these with `server.use`. */ export const handlers = [ - ...sampleAgentsHandlers, ...sampleDatasetsHandlers, // Evaluator V2 — fixtures loaded on first use to keep initial handler graph smaller diff --git a/web/packages/studio/src/mocks/handlers/sampleAgents.ts b/web/packages/studio/src/mocks/handlers/sampleAgents.ts deleted file mode 100644 index a4b06f2368..0000000000 --- a/web/packages/studio/src/mocks/handlers/sampleAgents.ts +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { http, HttpResponse } from 'msw'; - -// Realistic fixtures for the public/sample-agents/* static assets. The create -// flow parses the returned agent.yml, so these must be valid NAT config YAML — -// not a '{}' stub. -const PHISHING_AGENT_YAML = `functions: - email_phishing_analyzer: - _type: email_phishing_analyzer - llm: llm -llms: - llm: - _type: openai - api_key: not-used - model_name: \${NEMO_DEFAULT_MODEL} - temperature: 0.0 -workflow: - _type: tool_calling_agent - tool_names: [email_phishing_analyzer] - llm_name: llm -`; - -const CALCULATOR_AGENT_YAML = `function_groups: - calculator: - _type: calculator -functions: - current_datetime: - _type: current_datetime -llms: - llm: - _type: openai - api_key: not-used - model_name: \${NEMO_DEFAULT_MODEL} - temperature: 0.0 -workflow: - _type: react_agent - tool_names: [calculator, current_datetime] - llm_name: llm - use_native_tool_calling: true -`; - -const EVAL_YAML = `llms: - judge_llm: - _type: openai - model_name: nvidia-nemotron-3-super-120b-a12b -eval: - general: - dataset: - _type: csv - file_path: smaller_test.csv - evaluators: - accuracy: - _type: tunable_rag_evaluator - llm_name: judge_llm -`; - -/** Handlers for sample-agent static asset requests (paths relative to BASE_URL). */ -export const sampleAgentsHandlers = [ - http.get(/\/sample-agents\/.+/, ({ request }) => { - const path = new URL(request.url).pathname; - if (path.endsWith('/agent.yml')) { - const body = path.includes('/calculator/') ? CALCULATOR_AGENT_YAML : PHISHING_AGENT_YAML; - return HttpResponse.text(body, { headers: { 'Content-Type': 'application/yaml' } }); - } - if (path.endsWith('.yml')) { - return HttpResponse.text(EVAL_YAML, { headers: { 'Content-Type': 'application/yaml' } }); - } - if (path.endsWith('.csv')) { - return HttpResponse.text('subject,body,label\nHi,benign body,benign\n', { - headers: { 'Content-Type': 'text/csv' }, - }); - } - return HttpResponse.text('[]', { headers: { 'Content-Type': 'application/json' } }); - }), -]; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx index aaf4bf1db7..fe126a1368 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx @@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ControlledSearchableSelect } from '@nemo/common/src/components/form/ControlledSearchableSelect'; +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; import { FormModal } from '@nemo/common/src/components/FormModal'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; import { getAgentsListAgentsQueryKey, useAgentsCreateAgent } from '@nemo/sdk/generated/agents/api'; @@ -30,15 +31,22 @@ import type { import { getAgentDetailRoute, getAgentsListRoute } from '@studio/routes/utils'; import { buildSuggestedModelOptions, - pickDefaultModelName, + pickModelNameForExample, SUGGESTED_MODEL_GROUP_LABELS, } from '@studio/util/buildSuggestedModelOptions'; -import { useQueryClient } from '@tanstack/react-query'; -import { type FC, useEffect, useRef, useState } from 'react'; -import { type SubmitHandler, useForm } from 'react-hook-form'; +import { loadSampleAgentModelName } from '@studio/util/sampleAgents'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { type FC, useEffect, useMemo, useState } from 'react'; +import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { useNavigate } from 'react-router'; -export const CreateExampleAgentModal: FC = ({ +// Since useForm is called in the component itself, key-based remount needs a thin outer wrapper +// otherwise there's nothing to put the key on +export const CreateExampleAgentModal: FC = (props) => ( + +); + +const CreateExampleAgentModalInner: FC = ({ open, onClose, workspace, @@ -53,11 +61,11 @@ export const CreateExampleAgentModal: FC = ({ { page_size: DEFAULT_LARGE_PAGE_SIZE }, { query: { enabled: open && !!workspace } } ); - const models = modelsPage?.data ?? []; + const models = useMemo(() => modelsPage?.data ?? [], [modelsPage?.data]); const modelOptions = buildSuggestedModelOptions(models); - const exampleOptions = SAMPLE_AGENTS.map((example) => ({ + const exampleItems = SAMPLE_AGENTS.map((example) => ({ value: example.key, - label: example.label, + children: example.displayName, })); const { @@ -91,7 +99,7 @@ export const CreateExampleAgentModal: FC = ({ const { control, - reset: resetForm, + setValue, handleSubmit, formState: { errors }, } = useForm({ @@ -101,28 +109,30 @@ export const CreateExampleAgentModal: FC = ({ mode: 'onChange', }); - const seededRef = useRef(false); + const exampleKey = useWatch({ control, name: 'exampleKey' }); + + const { data: preferredModel } = useQuery({ + queryKey: ['sample-agent-model', exampleKey], + queryFn: () => loadSampleAgentModelName(getSampleAgent(exampleKey).agentConfigPath), + enabled: open && !!exampleKey, + staleTime: Infinity, + }); + + const defaultModel = useMemo( + () => pickModelNameForExample(models, preferredModel), + [models, preferredModel] + ); + useEffect(() => { - if (!open) { - seededRef.current = false; - resetForm({ exampleKey: DEFAULT_SAMPLE_AGENT_KEY, modelName: '' }); - return; - } - if (seededRef.current) return; - const defaultModel = pickDefaultModelName(models); - if (defaultModel) { - resetForm({ exampleKey: DEFAULT_SAMPLE_AGENT_KEY, modelName: defaultModel }); - seededRef.current = true; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, modelsPage, resetForm]); + if (!defaultModel) return; + setValue('modelName', defaultModel, { shouldValidate: true }); + }, [defaultModel, setValue]); const [loadError, setLoadError] = useState(undefined); const reset = () => { resetMutation(); setLoadError(undefined); - resetForm({ exampleKey: DEFAULT_SAMPLE_AGENT_KEY, modelName: '' }); }; const resetAndClose = () => { @@ -174,11 +184,10 @@ export const CreateExampleAgentModal: FC = ({ loading={isPending} errorText={errorMessage} > - exampleItems.find((item) => item.value === v)?.children} formFieldProps={{ slotLabel: 'Example', slotError: errors.exampleKey?.message, diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx deleted file mode 100644 index 12274a1135..0000000000 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx +++ /dev/null @@ -1,354 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { getAgentsListAgentsQueryKey } from '@nemo/sdk/generated/agents/api'; -import { getModelsListModelsQueryKey } from '@nemo/sdk/generated/platform/api'; -import { markExampleAgentIntroShown } from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthroughStorage'; -import { PLATFORM_BASE_URL } from '@studio/constants/environment'; -import { ROUTES } from '@studio/constants/routes'; -import { workspace1 } from '@studio/mocks/entity-store/projects'; -import { server } from '@studio/mocks/node'; -import { AgentsListRoute } from '@studio/routes/agents/AgentsListRoute'; -import { getAgentsListRoute } from '@studio/routes/utils'; -import { renderRoute, screen, waitFor } from '@studio/tests/util/render'; -import { within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; - -vi.mock('@studio/plugins/PluginContext', async (importOriginal) => ({ - ...(await importOriginal()), - usePluginsLoaded: () => true, - usePluginsError: () => false, - usePluginInstalled: () => true, -})); - -const workspace = workspace1.workspace; -const MODELS_URL = `${PLATFORM_BASE_URL}${getModelsListModelsQueryKey(':workspace')[0]}`; -const CREATE_AGENT_URL = `${PLATFORM_BASE_URL}${getAgentsListAgentsQueryKey(':workspace')[0]}`; - -const mockModels = (names: string[]) => { - server.use( - http.get(MODELS_URL, () => - HttpResponse.json({ - data: names.map((name) => ({ name, workspace })), - pagination: { - page: 1, - page_size: 50, - current_page_size: names.length, - total_pages: 1, - total_results: names.length, - }, - sort: '-created_at', - filter: null, - }) - ) - ); -}; - -const renderList = () => - renderRoute(undefined, { - history: getAgentsListRoute(workspace), - routes: [ - { path: ROUTES.workspace.agentsList, element: }, - { path: ROUTES.workspace.agentDetail, element:
Agent detail page
}, - ], - }); - -const openModal = async (user: ReturnType): Promise => { - await user.click(await screen.findByRole('button', { name: 'Create Example Agent' })); - const dialog = await screen.findByRole('dialog'); - await within(dialog).findByRole('combobox', { name: 'Model' }); - return dialog; -}; - -describe('AgentsListRoute', () => { - beforeEach(() => sessionStorage.clear()); - - it('renders the page shell', async () => { - renderList(); - expect(await screen.findByText('Agents')).toBeInTheDocument(); - expect( - screen.getByText('View and manage AI agents and their deployments.') - ).toBeInTheDocument(); - }); - - it('opens the modal with the suggested model preselected', async () => { - const user = userEvent.setup(); - mockModels(['nvidia-nemotron-nano-9b-v2']); - renderList(); - - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-nano-9b-v2' - ) - ); - }); - - it('creates the example agent with the default suggested model and onboards (navigates)', async () => { - const user = userEvent.setup(); - mockModels(['meta-llama-3-1-70b-instruct', 'nvidia-nemotron-super-49b']); - - let captured: { name?: string; description?: string; config?: Record } = {}; - server.use( - http.post(CREATE_AGENT_URL, async ({ request, params }) => { - captured = (await request.json()) as typeof captured; - return HttpResponse.json({ ...captured, workspace: params['workspace'] }); - }) - ); - - renderList(); - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-super-49b' - ) - ); - await user.click(within(dialog).getByRole('button', { name: 'Create' })); - - expect(await screen.findByText('Agent detail page')).toBeInTheDocument(); - - expect(captured.name).toMatch(/^calculator-demo-agent-[a-z0-9]{6}$/); - expect(captured.description).toBeTruthy(); - const config = captured.config as { - workflow: { _type: string; tool_names: string[]; use_native_tool_calling: boolean }; - function_groups: Record; - functions: Record; - llms: { llm: { model_name: string } }; - }; - expect(config.workflow._type).toBe('react_agent'); - expect(config.workflow.tool_names).toEqual(['calculator', 'current_datetime']); - expect(config.workflow.use_native_tool_calling).toBe(true); - expect(config.function_groups.calculator._type).toBe('calculator'); - expect(config.functions.current_datetime._type).toBe('current_datetime'); - expect(config.llms.llm.model_name).toBe('nvidia-nemotron-super-49b'); - }); - - it('lets the user pick a different model', async () => { - const user = userEvent.setup(); - mockModels(['nvidia-nemotron-super-49b', 'meta-llama-3-1-70b-instruct']); - - let modelName: string | undefined; - server.use( - http.post(CREATE_AGENT_URL, async ({ request, params }) => { - const body = (await request.json()) as { - config: { llms: { llm: { model_name: string } } }; - }; - modelName = body.config.llms.llm.model_name; - return HttpResponse.json({ - name: 'calculator-demo-agent-abc123', - workspace: params['workspace'], - }); - }) - ); - - renderList(); - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-super-49b' - ) - ); - - await user.click(within(dialog).getByRole('combobox', { name: 'Model' })); - await user.click(await screen.findByRole('option', { name: 'meta-llama-3-1-70b-instruct' })); - await user.click(within(dialog).getByRole('button', { name: 'Create' })); - - await waitFor(() => expect(modelName).toBe('meta-llama-3-1-70b-instruct')); - }); - - it('creates the email phishing example when that example is selected', async () => { - const user = userEvent.setup(); - mockModels(['nvidia-nemotron-super-49b']); - - let captured: { name?: string; description?: string; config?: Record } = {}; - server.use( - http.post(CREATE_AGENT_URL, async ({ request, params }) => { - captured = (await request.json()) as typeof captured; - return HttpResponse.json({ ...captured, workspace: params['workspace'] }); - }) - ); - - renderList(); - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-super-49b' - ) - ); - - await user.click(within(dialog).getByRole('combobox', { name: 'Example' })); - await user.click(await screen.findByRole('option', { name: 'email_phishing_analyzer' })); - await user.click(within(dialog).getByRole('button', { name: 'Create' })); - - await waitFor(() => expect(captured.name).toMatch(/^email-phishing-demo-agent-[a-z0-9]{6}$/)); - const config = captured.config as { - workflow: { tool_names: string[] }; - functions: Record; - llms: { llm: { model_name: string } }; - }; - expect(config.workflow.tool_names).toEqual(['email_phishing_analyzer']); - expect(config.functions.email_phishing_analyzer._type).toBe('email_phishing_analyzer'); - expect(config.llms.llm.model_name).toBe('nvidia-nemotron-super-49b'); - }); - - it('excludes non-chat models from the picker', async () => { - const user = userEvent.setup(); - mockModels(['nvidia-nv-embedqa-e5-v5', 'nvidia-nemotron-nano-9b-v2']); - renderList(); - - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-nano-9b-v2' - ) - ); - await user.click(within(dialog).getByRole('combobox', { name: 'Model' })); - - expect( - await screen.findByRole('option', { name: 'nvidia-nemotron-nano-9b-v2' }) - ).toBeInTheDocument(); - expect( - screen.queryByRole('option', { name: 'nvidia-nv-embedqa-e5-v5' }) - ).not.toBeInTheDocument(); - }); - - it('refetches the agents list after creating so the new agent appears immediately', async () => { - const user = userEvent.setup(); - mockModels(['nvidia-nemotron-nano-9b-v2']); - // Returning user → stays on the list, so the table query is still mounted. - markExampleAgentIntroShown(); - - let agentListFetches = 0; - server.use( - http.get(CREATE_AGENT_URL, () => { - agentListFetches += 1; - return HttpResponse.json({ - data: [], - pagination: { - page: 1, - page_size: 50, - current_page_size: 0, - total_pages: 1, - total_results: 0, - }, - sort: '-created_at', - filter: null, - }); - }), - http.post(CREATE_AGENT_URL, async ({ request, params }) => { - const body = (await request.json()) as { name?: string }; - return HttpResponse.json({ ...body, workspace: params['workspace'] }); - }) - ); - - renderList(); - await waitFor(() => expect(agentListFetches).toBeGreaterThan(0)); - const before = agentListFetches; - - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-nano-9b-v2' - ) - ); - await user.click(within(dialog).getByRole('button', { name: 'Create' })); - - // Create invalidates the list, triggering an immediate refetch (not the 15s poll). - await waitFor(() => expect(agentListFetches).toBeGreaterThan(before)); - }); - - it('does not onboard for a later example agent in the same session', async () => { - const user = userEvent.setup(); - mockModels(['nvidia-nemotron-nano-9b-v2']); - markExampleAgentIntroShown(); - - let created = false; - server.use( - http.post(CREATE_AGENT_URL, async ({ request, params }) => { - created = true; - const body = (await request.json()) as { name?: string }; - return HttpResponse.json({ ...body, workspace: params['workspace'] }); - }) - ); - - renderList(); - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-nano-9b-v2' - ) - ); - await user.click(within(dialog).getByRole('button', { name: 'Create' })); - - await waitFor(() => expect(created).toBe(true)); - expect(screen.queryByText('Agent detail page')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create Example Agent' })).toBeInTheDocument(); - }); - - it('does not onboard when an example agent already exists in the workspace', async () => { - const user = userEvent.setup(); - mockModels(['nvidia-nemotron-nano-9b-v2']); - server.use( - http.get(CREATE_AGENT_URL, () => - HttpResponse.json({ - data: [{ name: 'calculator-demo-agent-abc123', workspace }], - pagination: { - page: 1, - page_size: 50, - current_page_size: 1, - total_pages: 1, - total_results: 1, - }, - sort: '-created_at', - filter: null, - }) - ) - ); - - let created = false; - server.use( - http.post(CREATE_AGENT_URL, async ({ request, params }) => { - created = true; - const body = (await request.json()) as { name?: string }; - return HttpResponse.json({ ...body, workspace: params['workspace'] }); - }) - ); - - renderList(); - await screen.findByText('calculator-demo-agent-abc123'); - const dialog = await openModal(user); - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toHaveTextContent( - 'nvidia-nemotron-nano-9b-v2' - ) - ); - await user.click(within(dialog).getByRole('button', { name: 'Create' })); - - await waitFor(() => expect(created).toBe(true)); - expect(screen.queryByText('Agent detail page')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create Example Agent' })).toBeInTheDocument(); - }); - - it('does not create when the workspace has no models', async () => { - const user = userEvent.setup(); - mockModels([]); - - let createCalled = false; - server.use( - http.post(CREATE_AGENT_URL, () => { - createCalled = true; - return HttpResponse.json({ name: 'unexpected', workspace }); - }) - ); - - renderList(); - const dialog = await openModal(user); - await user.click(within(dialog).getByRole('button', { name: 'Create' })); - - await waitFor(() => - expect(within(dialog).getByRole('combobox', { name: 'Model' })).toBeInTheDocument() - ); - expect(createCalled).toBe(false); - }); -}); diff --git a/web/packages/studio/src/util/buildSuggestedModelOptions.ts b/web/packages/studio/src/util/buildSuggestedModelOptions.ts index d109cddd25..e21522e2ff 100644 --- a/web/packages/studio/src/util/buildSuggestedModelOptions.ts +++ b/web/packages/studio/src/util/buildSuggestedModelOptions.ts @@ -58,3 +58,13 @@ export const pickDefaultModelName = (models: ModelListEntry[]): string | undefin const names = models.map((m) => m.name); return names.find(isSuggested) ?? names.find(isLlmCandidate); }; + +export const pickModelNameForExample = ( + models: ModelListEntry[], + preferred: string | null | undefined +): string | undefined => { + if (preferred && models.some((m) => m.name === preferred && isLlmCandidate(m.name))) { + return preferred; + } + return pickDefaultModelName(models); +}; diff --git a/web/packages/studio/src/util/sampleAgents.ts b/web/packages/studio/src/util/sampleAgents.ts new file mode 100644 index 0000000000..d4f42bb9da --- /dev/null +++ b/web/packages/studio/src/util/sampleAgents.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fetchSampleText } from '@studio/api/agents/fetchSampleText'; +import YAML from 'yaml'; + +export const loadSampleAgentModelName = async (agentConfigPath: string): Promise => { + const text = await fetchSampleText(agentConfigPath); + let config: Record | undefined; + try { + config = YAML.parse(text) as Record | undefined; + } catch { + return null; + } + const llm = (config?.llms as { llm?: unknown } | undefined)?.llm; + if (!llm || typeof llm !== 'object' || Array.isArray(llm)) return null; + + const modelName = (llm as Record).model_name; + if (typeof modelName !== 'string' || modelName.includes('${')) return null; + + const bare = modelName.includes('/') ? (modelName.split('/').pop() ?? modelName) : modelName; + return bare.trim() || null; +};