From 37b5f17d901079a20ac204154537bacda1b6f881 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 20 Aug 2026 12:14:45 -0700 Subject: [PATCH 01/12] feat(studio): create Fabric agents by uploading their directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio could only create Fabric agents from a bundled sample config, so an agent's skills, MCP servers, and prompts had no way in. Add an Upload Agent flow that takes a directory containing agent.yaml and uploads it into the conventional {agent}-spec fileset that deployments read. The platform has no endpoint taking config and files together, so the flow runs in two phases: create the agent entity, which reserves the name and returns 409 on a duplicate, then upload the directory. Both are rolled back if either fails. An existing {agent}-spec fileset is refused rather than merged into, and the error names the fileset and how to remove it — deleting an agent deliberately leaves its fileset behind, so recreating an agent under a previous name is the case users will hit. Refusing up front is also what makes deleting the fileset safe during rollback: anything in it was put there by this flow. Build artifacts are dropped before the file-count and byte checks, so a __pycache__ cannot push an otherwise valid agent over a limit that the platform only enforces later, when a deployment stages the fileset. Signed-off-by: mschwab --- .../agents/useCreateAgentFromUpload.test.ts | 101 ++++++++++++ .../api/agents/useCreateAgentFromUpload.ts | 123 ++++++++++++++ .../UploadAgentModal/const.test.ts | 138 ++++++++++++++++ .../AgentsListRoute/UploadAgentModal/const.ts | 130 +++++++++++++++ .../UploadAgentModal/index.tsx | 155 ++++++++++++++++++ .../AgentsListRoute/UploadAgentModal/type.ts | 18 ++ .../routes/agents/AgentsListRoute/index.tsx | 18 +- 7 files changed, 680 insertions(+), 3 deletions(-) create mode 100644 web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts create mode 100644 web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/type.ts diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts new file mode 100644 index 0000000000..991690d673 --- /dev/null +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { agentsCreateAgent, agentsDeleteAgent } from '@nemo/sdk/generated/agents/api'; +import { + filesCreateFileset, + filesDeleteFileset, + filesRetrieveFileset, + filesUploadFile, +} from '@nemo/sdk/generated/platform/api'; +import { + AgentSpecFilesetConflictError, + createAgentFromUpload, +} from '@studio/api/agents/useCreateAgentFromUpload'; +import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; + +vi.mock('@nemo/sdk/generated/agents/api', async (importOriginal) => ({ + ...(await importOriginal()), + agentsCreateAgent: vi.fn(), + agentsDeleteAgent: vi.fn(), +})); + +vi.mock('@nemo/sdk/generated/platform/api', async (importOriginal) => ({ + ...(await importOriginal()), + filesRetrieveFileset: vi.fn(), + filesCreateFileset: vi.fn(), + filesUploadFile: vi.fn(), + filesDeleteFileset: vi.fn(), +})); + +const FABRIC_YAML = 'config_format: nemo-agents-spec-v1\nname: calc\ndescription: Adds numbers\n'; + +const entryFor = (path: string, contents: string): UploadAgentEntry => ({ + path, + file: new File([contents], path.split('/').pop() ?? path), +}); + +const entries = (): UploadAgentEntry[] => [ + entryFor('agent.yaml', FABRIC_YAML), + entryFor('mcps/calculator.py', 'print(1)\n'), +]; + +const params = () => ({ workspace: 'ws', name: 'calc', entries: entries() }); + +beforeEach(() => { + vi.mocked(filesRetrieveFileset).mockRejectedValue(new Error('404')); + vi.mocked(filesCreateFileset).mockResolvedValue({ name: 'calc-spec' } as never); + vi.mocked(filesUploadFile).mockResolvedValue({ path: 'agent.yaml' } as never); + vi.mocked(filesDeleteFileset).mockResolvedValue(undefined as never); + vi.mocked(agentsCreateAgent).mockResolvedValue({ name: 'calc' } as never); + vi.mocked(agentsDeleteAgent).mockResolvedValue(undefined as never); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('createAgentFromUpload', () => { + it('creates the agent, then uploads every file into the {agent}-spec fileset', async () => { + await createAgentFromUpload(params()); + + expect(agentsCreateAgent).toHaveBeenCalledWith('ws', { + name: 'calc', + description: 'Adds numbers', + config: expect.objectContaining({ config_format: 'nemo-agents-spec-v1' }), + config_format: 'nemo-agents-spec-v1', + }); + expect(filesCreateFileset).toHaveBeenCalledWith('ws', expect.objectContaining({ name: 'calc-spec' })); + expect(vi.mocked(filesUploadFile).mock.calls.map((call) => [call[1], call[2]])).toEqual([ + ['calc-spec', 'agent.yaml'], + ['calc-spec', 'mcps/calculator.py'], + ]); + }); + + it('refuses to touch an existing spec fileset', async () => { + vi.mocked(filesRetrieveFileset).mockResolvedValue({ name: 'calc-spec' } as never); + + await expect(createAgentFromUpload(params())).rejects.toThrow(AgentSpecFilesetConflictError); + expect(agentsCreateAgent).not.toHaveBeenCalled(); + expect(filesCreateFileset).not.toHaveBeenCalled(); + }); + + it('deletes the agent and the fileset when an upload fails', async () => { + vi.mocked(filesUploadFile) + .mockResolvedValueOnce({ path: 'agent.yaml' } as never) + .mockRejectedValueOnce(new Error('network down')); + + await expect(createAgentFromUpload(params())).rejects.toThrow('network down'); + expect(agentsDeleteAgent).toHaveBeenCalledWith('ws', 'calc'); + expect(filesDeleteFileset).toHaveBeenCalledWith('ws', 'calc-spec'); + }); + + it('rejects a non-Fabric config before creating anything', async () => { + const natEntries = [entryFor('agent.yaml', 'config_format: nat-workflow-v1\n')]; + + await expect( + createAgentFromUpload({ workspace: 'ws', name: 'calc', entries: natEntries }) + ).rejects.toThrow(/config_format/); + expect(agentsCreateAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts new file mode 100644 index 0000000000..4317cc2623 --- /dev/null +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { agentsCreateAgent, agentsDeleteAgent } from '@nemo/sdk/generated/agents/api'; +import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; +import { + filesCreateFileset, + filesDeleteFileset, + filesRetrieveFileset, + filesUploadFile, +} from '@nemo/sdk/generated/platform/api'; +import { + AGENT_CONFIG_FILENAME, + agentSpecFilesetName, + FABRIC_CONFIG_FORMAT, + parseAgentConfig, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; + +export interface CreateAgentFromUploadParams { + workspace: string; + name: string; + entries: UploadAgentEntry[]; +} + +export class AgentSpecFilesetConflictError extends Error { + constructor(public readonly filesetName: string) { + super( + `A fileset named "${filesetName}" already exists. It holds the spec for an agent of this name — possibly one that was deleted, since deleting an agent leaves its fileset behind. Delete it with \`nemo files filesets delete ${filesetName}\`, or choose a different name.` + ); + } +} + +/** + * Create a Fabric agent from a picked directory. + * + * Two phases, because the platform has no endpoint that takes config and files + * together: the agent entity is created first so the name is reserved (and a + * duplicate returns 409), then the directory is uploaded into the conventional + * `{agent}-spec` fileset that deployments read. + * + * Both are rolled back on failure. Deleting the fileset is safe here only + * because an existing one is refused up front — so anything this flow uploaded + * into it, this flow created. + */ +export const createAgentFromUpload = async ({ + workspace, + name, + entries, +}: CreateAgentFromUploadParams): Promise => { + const filesetName = agentSpecFilesetName(name); + + await assertFilesetAvailable(workspace, filesetName); + + const configEntry = entries.find((entry) => entry.path === AGENT_CONFIG_FILENAME); + if (!configEntry) throw new Error(`No ${AGENT_CONFIG_FILENAME} in the selected directory.`); + const config = parseAgentConfig(await configEntry.file.text()); + + const agent = await agentsCreateAgent(workspace, { + name, + description: typeof config.description === 'string' ? config.description : '', + config, + config_format: FABRIC_CONFIG_FORMAT, + }); + + try { + await filesCreateFileset(workspace, { + name: filesetName, + description: `Agent spec for ${name}`, + }); + await uploadEntries(workspace, filesetName, entries); + } catch (error) { + await rollback(workspace, name, filesetName); + throw error; + } + + return agent; +}; + +const assertFilesetAvailable = async (workspace: string, filesetName: string): Promise => { + try { + await filesRetrieveFileset(workspace, filesetName); + } catch { + return; + } + throw new AgentSpecFilesetConflictError(filesetName); +}; + +/** + * Upload sequentially. The files service takes one file per request, and a + * directory is bounded at 500 files, so ordered failure beats saturating the + * browser's connection pool for a marginal speedup. + */ +const uploadEntries = async ( + workspace: string, + filesetName: string, + entries: UploadAgentEntry[] +): Promise => { + for (const entry of entries) { + const blob = new Blob([await entry.file.arrayBuffer()], { type: 'application/octet-stream' }); + await filesUploadFile(workspace, filesetName, entry.path, blob); + } +}; + +const rollback = async ( + workspace: string, + agentName: string, + filesetName: string +): Promise => { + await Promise.allSettled([ + agentsDeleteAgent(workspace, agentName), + filesDeleteFileset(workspace, filesetName), + ]); +}; + +export type UseCreateAgentFromUploadOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export const useCreateAgentFromUpload = (options?: UseCreateAgentFromUploadOptions) => + useMutation({ ...options, mutationFn: createAgentFromUpload }); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts new file mode 100644 index 0000000000..478cb3f2dd --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AgentConfigParseError, + agentNameFromConfig, + agentSpecFilesetName, + collectAgentEntries, + isIgnoredPath, + MAX_AGENT_SPEC_FILES, + parseAgentConfig, + validateAgentEntries, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; + +const makeFile = (relativePath: string, contents = 'x'): File => { + const file = new File([contents], relativePath.split('/').pop() ?? relativePath); + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }); + return file; +}; + +const entry = (path: string, size = 1): UploadAgentEntry => ({ + path, + file: { size } as File, +}); + +describe('collectAgentEntries', () => { + it('strips the picked directory from each path', () => { + const entries = collectAgentEntries([ + makeFile('calculator-agent/agent.yaml'), + makeFile('calculator-agent/mcps/calculator.py'), + ]); + + expect(entries.map((item) => item.path)).toEqual(['agent.yaml', 'mcps/calculator.py']); + }); + + it('drops build artifacts that the container path cannot stage', () => { + const entries = collectAgentEntries([ + makeFile('agent/agent.yaml'), + makeFile('agent/mcps/__pycache__/calculator.cpython-312.pyc'), + makeFile('agent/.DS_Store'), + makeFile('agent/.git/config'), + makeFile('agent/node_modules/left-pad/index.js'), + ]); + + expect(entries.map((item) => item.path)).toEqual(['agent.yaml']); + }); + + it('falls back to the file name when the picker reports no relative path', () => { + const entries = collectAgentEntries([new File(['x'], 'agent.yaml')]); + + expect(entries.map((item) => item.path)).toEqual(['agent.yaml']); + }); +}); + +describe('isIgnoredPath', () => { + it.each([ + ['mcps/__pycache__/calculator.pyc', true], + ['.venv/lib/python3.12/site-packages/x.py', true], + ['build/libthing.so', true], + ['skills/review/SKILL.md', false], + ['mcps/calculator.py', false], + ])('%s -> %s', (path, expected) => { + expect(isIgnoredPath(path)).toBe(expected); + }); +}); + +describe('validateAgentEntries', () => { + it('requires agent.yaml at the top level', () => { + expect(validateAgentEntries([entry('mcps/calculator.py')])).toMatch(/No agent\.yaml/); + expect(validateAgentEntries([entry('nested/agent.yaml')])).toMatch(/No agent\.yaml/); + }); + + it('rejects an empty directory', () => { + expect(validateAgentEntries([])).toMatch(/no uploadable files/); + }); + + it('rejects a directory over the file-count limit', () => { + const entries = [ + entry('agent.yaml'), + ...Array.from({ length: MAX_AGENT_SPEC_FILES }, (_unused, index) => entry(`f${index}.md`)), + ]; + + expect(validateAgentEntries(entries)).toMatch(/the limit is 500/); + }); + + it('rejects a directory over the byte limit', () => { + expect(validateAgentEntries([entry('agent.yaml', 900_001)])).toMatch(/the limit is 900 KB/); + }); + + it('accepts a directory within both limits', () => { + expect(validateAgentEntries([entry('agent.yaml'), entry('mcps/calculator.py')])).toBeUndefined(); + }); +}); + +describe('parseAgentConfig', () => { + it('returns the parsed config for the Fabric contract', () => { + const config = parseAgentConfig('config_format: nemo-agents-spec-v1\nname: calc\n'); + + expect(config.name).toBe('calc'); + }); + + it('rejects a NAT workflow config', () => { + expect(() => parseAgentConfig('config_format: nat-workflow-v1\n')).toThrow( + AgentConfigParseError + ); + }); + + it('rejects a config with no config_format', () => { + expect(() => parseAgentConfig('name: calc\n')).toThrow(AgentConfigParseError); + }); + + it('rejects YAML that is not a mapping', () => { + expect(() => parseAgentConfig('- one\n- two\n')).toThrow(/must contain a YAML mapping/); + }); + + it('rejects malformed YAML', () => { + expect(() => parseAgentConfig('a:\n - b\n c: broken\n')).toThrow(/not valid YAML/); + }); +}); + +describe('agentNameFromConfig', () => { + it('reads a non-empty name', () => { + expect(agentNameFromConfig({ name: ' calc ' })).toBe('calc'); + }); + + it('ignores a missing or blank name', () => { + expect(agentNameFromConfig({})).toBeUndefined(); + expect(agentNameFromConfig({ name: ' ' })).toBeUndefined(); + expect(agentNameFromConfig({ name: 7 })).toBeUndefined(); + }); +}); + +describe('agentSpecFilesetName', () => { + it('matches the platform convention', () => { + expect(agentSpecFilesetName('calc')).toBe('calc-spec'); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts new file mode 100644 index 0000000000..b8125cd870 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; +import YAML from 'yaml'; +import { z } from 'zod'; + +export const AGENT_CONFIG_FILENAME = 'agent.yaml'; +export const FABRIC_CONFIG_FORMAT = 'nemo-agents-spec-v1'; + +// Mirrors MAX_AGENT_SPEC_STAGED_BYTES / _FILES in nemo_agents_plugin.entities. The +// platform only enforces these when the deployment stages the fileset, which is long +// after the upload, so the same limits are checked here to fail while the user is looking. +export const MAX_AGENT_SPEC_BYTES = 900_000; +export const MAX_AGENT_SPEC_FILES = 500; + +const IGNORED_DIRECTORIES = new Set([ + '__pycache__', + '.git', + '.venv', + 'venv', + 'node_modules', + '.mypy_cache', + '.pytest_cache', + '.ruff_cache', + '.idea', + '.vscode', +]); + +const IGNORED_FILENAMES = new Set(['.DS_Store', 'Thumbs.db']); + +const IGNORED_EXTENSIONS = ['.pyc', '.pyo', '.pyd', '.so', '.dylib', '.dll']; + +export const uploadAgentFormSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Use lowercase letters, numbers, and hyphens'), +}); + +/** Fileset holding an agent's spec. Convention only — the Agent entity stores no reference. */ +export const agentSpecFilesetName = (agentName: string): string => `${agentName}-spec`; + +export const isIgnoredPath = (path: string): boolean => { + const segments = path.split('/'); + if (segments.some((segment) => IGNORED_DIRECTORIES.has(segment))) return true; + + const filename = segments[segments.length - 1] ?? ''; + if (IGNORED_FILENAMES.has(filename)) return true; + + return IGNORED_EXTENSIONS.some((extension) => filename.endsWith(extension)); +}; + +/** + * Map picked files to fileset-relative paths, dropping build artifacts. + * + * A directory picker reports `webkitRelativePath` rooted at the chosen directory + * (`calculator-agent/mcps/calculator.py`); the fileset holds the contents, not the + * directory itself, so the first segment is stripped. + */ +export const collectAgentEntries = (files: File[]): UploadAgentEntry[] => { + const entries: UploadAgentEntry[] = []; + + for (const file of files) { + const relativePath = file.webkitRelativePath || file.name; + const path = relativePath.split('/').slice(1).join('/') || file.name; + if (!path || isIgnoredPath(path)) continue; + entries.push({ path, file }); + } + + return entries.sort((left, right) => left.path.localeCompare(right.path)); +}; + +export const totalEntryBytes = (entries: UploadAgentEntry[]): number => + entries.reduce((total, entry) => total + entry.file.size, 0); + +/** Returns the first blocking problem with the picked directory, or undefined. */ +export const validateAgentEntries = (entries: UploadAgentEntry[]): string | undefined => { + if (entries.length === 0) return 'That directory has no uploadable files.'; + + if (!entries.some((entry) => entry.path === AGENT_CONFIG_FILENAME)) { + return `No ${AGENT_CONFIG_FILENAME} at the top level of that directory.`; + } + + if (entries.length > MAX_AGENT_SPEC_FILES) { + return `That directory holds ${entries.length} files; the limit is ${MAX_AGENT_SPEC_FILES}. Point at a directory containing only the agent's own files.`; + } + + const bytes = totalEntryBytes(entries); + if (bytes > MAX_AGENT_SPEC_BYTES) { + return `That directory is ${Math.round(bytes / 1000)} KB; the limit is ${Math.round(MAX_AGENT_SPEC_BYTES / 1000)} KB. Point at a directory containing only the agent's own files.`; + } + + return undefined; +}; + +export class AgentConfigParseError extends Error {} + +/** Parse `agent.yaml` and confirm it is the Platform-owned Fabric contract. */ +export const parseAgentConfig = (text: string): Record => { + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch (error) { + throw new AgentConfigParseError( + `${AGENT_CONFIG_FILENAME} is not valid YAML: ${(error as Error).message}` + ); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new AgentConfigParseError(`${AGENT_CONFIG_FILENAME} must contain a YAML mapping.`); + } + + const config = parsed as Record; + const configFormat = config.config_format; + if (configFormat !== FABRIC_CONFIG_FORMAT) { + throw new AgentConfigParseError( + `${AGENT_CONFIG_FILENAME} must set config_format: ${FABRIC_CONFIG_FORMAT}${ + typeof configFormat === 'string' ? ` (found ${configFormat})` : '' + }.` + ); + } + + return config; +}; + +/** The config's own name, used to prefill the form. */ +export const agentNameFromConfig = (config: Record): string | undefined => + typeof config.name === 'string' && config.name.trim() ? config.name.trim() : undefined; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx new file mode 100644 index 0000000000..d55885179e --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { zodResolver } from '@hookform/resolvers/zod'; +import { getErrorMessage } from '@nemo/common/src/api/common/utils'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { FormModal } from '@nemo/common/src/components/FormModal'; +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { getAgentsListAgentsQueryKey } from '@nemo/sdk/generated/agents/api'; +import { Button, Stack, Text } from '@nvidia/foundations-react-core'; +import { useCreateAgentFromUpload } from '@studio/api/agents/useCreateAgentFromUpload'; +import { + AGENT_CONFIG_FILENAME, + agentNameFromConfig, + collectAgentEntries, + parseAgentConfig, + totalEntryBytes, + uploadAgentFormSchema, + validateAgentEntries, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { + UploadAgentEntry, + UploadAgentFormData, + UploadAgentModalProps, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; +import { getAgentDetailRoute } from '@studio/routes/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { type ChangeEventHandler, type FC, useEffect, useRef, useState } from 'react'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; + +export const UploadAgentModal: FC = ({ open, onClose, workspace }) => { + const toast = useToast(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const inputRef = useRef(null); + const [entries, setEntries] = useState([]); + const [directoryName, setDirectoryName] = useState(''); + const [selectionError, setSelectionError] = useState(undefined); + + // webkitdirectory is not in React's input attribute types; set it on the node instead. + useEffect(() => { + inputRef.current?.setAttribute('webkitdirectory', ''); + }, [open]); + + const { + mutateAsync: createAgent, + error: createError, + isPending, + reset: resetMutation, + } = useCreateAgentFromUpload({ + onSuccess: (agent) => { + toast.success(`Agent "${agent.name}" created`); + void queryClient.invalidateQueries({ queryKey: getAgentsListAgentsQueryKey(workspace) }); + resetAndClose(); + if (agent.name) navigate(getAgentDetailRoute(workspace, agent.name)); + }, + }); + + const { + control, + setValue, + handleSubmit, + reset: resetForm, + formState: { errors }, + } = useForm({ + resolver: zodResolver(uploadAgentFormSchema), + defaultValues: { name: '' }, + disabled: isPending, + mode: 'onChange', + }); + + const resetAndClose = () => { + resetMutation(); + resetForm({ name: '' }); + setEntries([]); + setDirectoryName(''); + setSelectionError(undefined); + onClose(); + }; + + const onDirectoryPicked: ChangeEventHandler = async (event) => { + const picked = Array.from(event.target.files ?? []); + event.target.value = ''; + if (picked.length === 0) return; + + setDirectoryName(picked[0]?.webkitRelativePath.split('/')[0] ?? ''); + const collected = collectAgentEntries(picked); + + const problem = validateAgentEntries(collected); + if (problem) { + setEntries([]); + setSelectionError(problem); + return; + } + + const configEntry = collected.find((item) => item.path === AGENT_CONFIG_FILENAME); + try { + const config = parseAgentConfig((await configEntry?.file.text()) ?? ''); + setValue('name', agentNameFromConfig(config) ?? '', { shouldValidate: true }); + } catch (error) { + setEntries([]); + setSelectionError(getErrorMessage(error as Error, `Could not read ${AGENT_CONFIG_FILENAME}`)); + return; + } + + setEntries(collected); + setSelectionError(undefined); + }; + + const onSubmit: SubmitHandler = async (formData) => { + try { + await createAgent({ workspace, name: formData.name.trim(), entries }); + } catch { + // surfaced via errorText + } + }; + + const errorMessage = + selectionError ?? + (createError ? getErrorMessage(createError as Error, 'Failed to create agent') : undefined); + + return ( + + + + + {entries.length > 0 ? ( + + {`${directoryName} — ${entries.length} files, ${Math.max(1, Math.round(totalEntryBytes(entries) / 1000))} KB`} + + ) : null} + + + + ); +}; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/type.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/type.ts new file mode 100644 index 0000000000..c7bc03a656 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/type.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FormModalProps } from '@nemo/common/src/components/FormModal'; +import type { uploadAgentFormSchema } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import type { z } from 'zod'; + +export type UploadAgentFormData = z.infer; + +/** A picked file paired with its path inside the agent spec fileset. */ +export interface UploadAgentEntry { + path: string; + file: File; +} + +export interface UploadAgentModalProps extends Pick { + workspace: string; +} diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx index 30d1bf07fa..8b389a3f51 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx @@ -15,6 +15,7 @@ import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal'; import { CloneAgentModal } from '@studio/routes/agents/AgentsListRoute/CloneAgentModal'; import { CreateExampleAgentModal } from '@studio/routes/agents/AgentsListRoute/CreateExampleAgentModal'; +import { UploadAgentModal } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal'; import { getAgentDetailRoute } from '@studio/routes/utils'; import { CircleAlert } from 'lucide-react'; import { type FC, useState } from 'react'; @@ -28,6 +29,7 @@ export const AgentsListRoute: FC = () => { const navigate = useNavigate(); const [createDeploymentAgent, setCreateDeploymentAgent] = useState(null); const [isCreateExampleOpen, setCreateExampleOpen] = useState(false); + const [isUploadOpen, setUploadOpen] = useState(false); const [cloneSource, setCloneSource] = useState(null); const [loadedAgents, setLoadedAgents] = useState([]); @@ -64,9 +66,14 @@ export const AgentsListRoute: FC = () => { slotHeading="Agents" slotDescription="View and manage AI agents and their deployments." slotActions={ - + + + + } /> { workspace={workspace} existingAgents={loadedAgents} /> + setUploadOpen(false)} + workspace={workspace} + /> setCloneSource(null)} From a9291d7d76aafe477a0a05faaa3bf6d9034f8a9b Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 20 Aug 2026 12:30:34 -0700 Subject: [PATCH 02/12] feat(studio): reject non-UTF-8 files before uploading an agent Container deployments read every staged file as UTF-8 text and fail the whole deployment on a decode error, while subprocess deployments never read the contents. A binary file therefore uploaded cleanly, ran locally, and only broke when someone deployed the agent to docker or k8s. Decode each picked file with a fatal TextDecoder and name the first that is not text, before the agent or its fileset is created. AGENT-SPEC.md is exempt, matching container staging, which skips it. The ignore list already drops build artifacts silently; this reports anything else by name rather than discarding files the user chose to include. Signed-off-by: mschwab --- .../UploadAgentModal/const.test.ts | 44 +++++++++++++++++++ .../AgentsListRoute/UploadAgentModal/const.ts | 29 ++++++++++++ .../UploadAgentModal/index.tsx | 10 +++++ 3 files changed, 83 insertions(+) diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts index 478cb3f2dd..e2a29ebef1 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.test.ts @@ -6,6 +6,7 @@ import { agentNameFromConfig, agentSpecFilesetName, collectAgentEntries, + findNonUtf8Path, isIgnoredPath, MAX_AGENT_SPEC_FILES, parseAgentConfig, @@ -136,3 +137,46 @@ describe('agentSpecFilesetName', () => { expect(agentSpecFilesetName('calc')).toBe('calc-spec'); }); }); + +describe('findNonUtf8Path', () => { + const binaryEntry = (path: string, bytes: number[]): UploadAgentEntry => ({ + path, + file: new File([new Uint8Array(bytes)], path.split('/').pop() ?? path), + }); + + const textEntry = (path: string, contents: string): UploadAgentEntry => ({ + path, + file: new File([contents], path.split('/').pop() ?? path), + }); + + it('names the first file that is not valid UTF-8', async () => { + const entries = [ + textEntry('agent.yaml', 'name: calc\n'), + binaryEntry('logo.bin', [0xff, 0xfe, 0x00, 0x62]), + ]; + + await expect(findNonUtf8Path(entries)).resolves.toBe('logo.bin'); + }); + + it('accepts multi-byte UTF-8, an empty file, and a BOM', async () => { + const entries = [ + textEntry('agent.yaml', 'description: café ☕ — 名前\n'), + textEntry('empty.md', ''), + binaryEntry('bom.md', [0xef, 0xbb, 0xbf, 0x68, 0x69]), + ]; + + await expect(findNonUtf8Path(entries)).resolves.toBeUndefined(); + }); + + it('ignores AGENT-SPEC.md, which container staging never reads', async () => { + const entries = [binaryEntry('AGENT-SPEC.md', [0xff, 0xfe, 0x00])]; + + await expect(findNonUtf8Path(entries)).resolves.toBeUndefined(); + }); + + it('rejects a lone UTF-16 surrogate sequence', async () => { + const entries = [binaryEntry('utf16.md', [0xed, 0xa0, 0x80])]; + + await expect(findNonUtf8Path(entries)).resolves.toBe('utf16.md'); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts index b8125cd870..c1fb072bf7 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts @@ -8,6 +8,9 @@ import { z } from 'zod'; export const AGENT_CONFIG_FILENAME = 'agent.yaml'; export const FABRIC_CONFIG_FORMAT = 'nemo-agents-spec-v1'; +// Container staging skips this file, so its bytes never reach a deployment. +const AGENT_SPEC_FILENAME = 'AGENT-SPEC.md'; + // Mirrors MAX_AGENT_SPEC_STAGED_BYTES / _FILES in nemo_agents_plugin.entities. The // platform only enforces these when the deployment stages the fileset, which is long // after the upload, so the same limits are checked here to fail while the user is looking. @@ -95,6 +98,32 @@ export const validateAgentEntries = (entries: UploadAgentEntry[]): string | unde return undefined; }; +/** + * Return the first file that is not valid UTF-8, or undefined. + * + * Container deployments read every staged file with `read_text(encoding="utf-8")` + * and refuse the whole deployment on a decode error, so a binary file uploads + * fine and then breaks the agent at deploy time — in docker and k8s only, since + * subprocess deployments never read the contents. The ignore list already drops + * the usual culprits (`.pyc`, `.so`); this catches the rest. + */ +export const findNonUtf8Path = async ( + entries: UploadAgentEntry[] +): Promise => { + const decoder = new TextDecoder('utf-8', { fatal: true }); + + for (const entry of entries) { + if (entry.path.split('/').pop() === AGENT_SPEC_FILENAME) continue; + try { + decoder.decode(await entry.file.arrayBuffer()); + } catch { + return entry.path; + } + } + + return undefined; +}; + export class AgentConfigParseError extends Error {} /** Parse `agent.yaml` and confirm it is the Platform-owned Fabric contract. */ diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx index d55885179e..9670d184ce 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -13,6 +13,7 @@ import { AGENT_CONFIG_FILENAME, agentNameFromConfig, collectAgentEntries, + findNonUtf8Path, parseAgentConfig, totalEntryBytes, uploadAgentFormSchema, @@ -95,6 +96,15 @@ export const UploadAgentModal: FC = ({ open, onClose, wor return; } + const binaryPath = await findNonUtf8Path(collected); + if (binaryPath) { + setEntries([]); + setSelectionError( + `${binaryPath} is not a text file. Agent files are delivered to container deployments as text, so the agent would fail to deploy. Remove it and try again.` + ); + return; + } + const configEntry = collected.find((item) => item.path === AGENT_CONFIG_FILENAME); try { const config = parseAgentConfig((await configEntry?.file.text()) ?? ''); From ac42e2fe1894730bad906dc2570241dd1da8b2be Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 20 Aug 2026 13:29:45 -0700 Subject: [PATCH 03/12] fix(studio): surface upload errors instead of the generic fallback getErrorMessage ends in `fallbackMessage ?? error.message`, so passing a fallback beats a plain Error's own message. The fileset conflict and the agent.yaml parse error both raise plain Errors, so the modal showed only "Failed to create agent" and the user never saw which fileset collided or how to remove it. Drop the fallback argument and apply the default after, which leaves axios detail extraction intact. Found by driving the flow in a browser; every unit test passed throughout, since they assert the flow raises the error rather than that the UI shows it. Also trim the comments in this flow back to the non-obvious ones. Signed-off-by: mschwab --- .../api/agents/useCreateAgentFromUpload.ts | 20 +++----------- .../AgentsListRoute/UploadAgentModal/const.ts | 27 +++---------------- .../UploadAgentModal/index.tsx | 9 ++++--- 3 files changed, 13 insertions(+), 43 deletions(-) diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts index 4317cc2623..c311819c4a 100644 --- a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts @@ -32,18 +32,9 @@ export class AgentSpecFilesetConflictError extends Error { } } -/** - * Create a Fabric agent from a picked directory. - * - * Two phases, because the platform has no endpoint that takes config and files - * together: the agent entity is created first so the name is reserved (and a - * duplicate returns 409), then the directory is uploaded into the conventional - * `{agent}-spec` fileset that deployments read. - * - * Both are rolled back on failure. Deleting the fileset is safe here only - * because an existing one is refused up front — so anything this flow uploaded - * into it, this flow created. - */ +// Two phases: no endpoint takes config and files together. Creating the agent first +// reserves the name. Deleting the fileset on rollback is safe only because an existing +// one is refused up front, so anything in it was put there by this flow. export const createAgentFromUpload = async ({ workspace, name, @@ -87,11 +78,6 @@ const assertFilesetAvailable = async (workspace: string, filesetName: string): P throw new AgentSpecFilesetConflictError(filesetName); }; -/** - * Upload sequentially. The files service takes one file per request, and a - * directory is bounded at 500 files, so ordered failure beats saturating the - * browser's connection pool for a marginal speedup. - */ const uploadEntries = async ( workspace: string, filesetName: string, diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts index c1fb072bf7..1441996576 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts @@ -11,9 +11,7 @@ export const FABRIC_CONFIG_FORMAT = 'nemo-agents-spec-v1'; // Container staging skips this file, so its bytes never reach a deployment. const AGENT_SPEC_FILENAME = 'AGENT-SPEC.md'; -// Mirrors MAX_AGENT_SPEC_STAGED_BYTES / _FILES in nemo_agents_plugin.entities. The -// platform only enforces these when the deployment stages the fileset, which is long -// after the upload, so the same limits are checked here to fail while the user is looking. +// Mirrors MAX_AGENT_SPEC_STAGED_BYTES / _FILES; the platform only enforces them at deploy. export const MAX_AGENT_SPEC_BYTES = 900_000; export const MAX_AGENT_SPEC_FILES = 500; @@ -42,7 +40,7 @@ export const uploadAgentFormSchema = z.object({ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Use lowercase letters, numbers, and hyphens'), }); -/** Fileset holding an agent's spec. Convention only — the Agent entity stores no reference. */ +/** Convention only — the Agent entity stores no reference to it. */ export const agentSpecFilesetName = (agentName: string): string => `${agentName}-spec`; export const isIgnoredPath = (path: string): boolean => { @@ -55,13 +53,7 @@ export const isIgnoredPath = (path: string): boolean => { return IGNORED_EXTENSIONS.some((extension) => filename.endsWith(extension)); }; -/** - * Map picked files to fileset-relative paths, dropping build artifacts. - * - * A directory picker reports `webkitRelativePath` rooted at the chosen directory - * (`calculator-agent/mcps/calculator.py`); the fileset holds the contents, not the - * directory itself, so the first segment is stripped. - */ +/** The fileset holds the directory's contents, so the picked root is stripped from each path. */ export const collectAgentEntries = (files: File[]): UploadAgentEntry[] => { const entries: UploadAgentEntry[] = []; @@ -78,7 +70,6 @@ export const collectAgentEntries = (files: File[]): UploadAgentEntry[] => { export const totalEntryBytes = (entries: UploadAgentEntry[]): number => entries.reduce((total, entry) => total + entry.file.size, 0); -/** Returns the first blocking problem with the picked directory, or undefined. */ export const validateAgentEntries = (entries: UploadAgentEntry[]): string | undefined => { if (entries.length === 0) return 'That directory has no uploadable files.'; @@ -98,15 +89,7 @@ export const validateAgentEntries = (entries: UploadAgentEntry[]): string | unde return undefined; }; -/** - * Return the first file that is not valid UTF-8, or undefined. - * - * Container deployments read every staged file with `read_text(encoding="utf-8")` - * and refuse the whole deployment on a decode error, so a binary file uploads - * fine and then breaks the agent at deploy time — in docker and k8s only, since - * subprocess deployments never read the contents. The ignore list already drops - * the usual culprits (`.pyc`, `.so`); this catches the rest. - */ +// Container deployments read every staged file as text and fail on a decode error. export const findNonUtf8Path = async ( entries: UploadAgentEntry[] ): Promise => { @@ -126,7 +109,6 @@ export const findNonUtf8Path = async ( export class AgentConfigParseError extends Error {} -/** Parse `agent.yaml` and confirm it is the Platform-owned Fabric contract. */ export const parseAgentConfig = (text: string): Record => { let parsed: unknown; try { @@ -154,6 +136,5 @@ export const parseAgentConfig = (text: string): Record => { return config; }; -/** The config's own name, used to prefill the form. */ export const agentNameFromConfig = (config: Record): string | undefined => typeof config.name === 'string' && config.name.trim() ? config.name.trim() : undefined; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx index 9670d184ce..cf29012353 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -40,7 +40,7 @@ export const UploadAgentModal: FC = ({ open, onClose, wor const [directoryName, setDirectoryName] = useState(''); const [selectionError, setSelectionError] = useState(undefined); - // webkitdirectory is not in React's input attribute types; set it on the node instead. + // webkitdirectory is absent from React's input attribute types. useEffect(() => { inputRef.current?.setAttribute('webkitdirectory', ''); }, [open]); @@ -111,7 +111,9 @@ export const UploadAgentModal: FC = ({ open, onClose, wor setValue('name', agentNameFromConfig(config) ?? '', { shouldValidate: true }); } catch (error) { setEntries([]); - setSelectionError(getErrorMessage(error as Error, `Could not read ${AGENT_CONFIG_FILENAME}`)); + setSelectionError( + getErrorMessage(error as Error) || `Could not read ${AGENT_CONFIG_FILENAME}` + ); return; } @@ -127,9 +129,10 @@ export const UploadAgentModal: FC = ({ open, onClose, wor } }; + // No fallback argument: getErrorMessage prefers one over a plain Error's own message. const errorMessage = selectionError ?? - (createError ? getErrorMessage(createError as Error, 'Failed to create agent') : undefined); + (createError ? getErrorMessage(createError as Error) || 'Failed to create agent' : undefined); return ( Date: Thu, 20 Aug 2026 14:42:27 -0700 Subject: [PATCH 04/12] refactor(studio): upload the agent directory before creating the entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating the entity first reserved the name but left create-time validation with nothing to inspect: plan and doctor need a base_dir, and at that point the fileset is empty. Uploading first reserves the name just as well, since the conflict check keys on the fileset, and it leaves the files in place for a POST /agents that later wants to validate against them. An existing spec fileset is now two different situations. If an agent of that name owns it, the name is taken and the user picks another. If nothing owns it — an abandoned upload, or an agent that was deleted, since deletion leaves the fileset behind — the next submit offers to replace it. That matters more after this change, because a closed tab mid-upload now leaves an orphan, and the previous rule made the name unusable until someone deleted it by hand. Editing the name clears an armed replace so it cannot target a fileset the user never saw the warning for. Signed-off-by: mschwab --- .../agents/useCreateAgentFromUpload.test.ts | 85 +++++++++++++++---- .../api/agents/useCreateAgentFromUpload.ts | 79 +++++++++++------ .../UploadAgentModal/index.tsx | 30 +++++-- 3 files changed, 145 insertions(+), 49 deletions(-) diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts index 991690d673..58215de4a7 100644 --- a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { agentsCreateAgent, agentsDeleteAgent } from '@nemo/sdk/generated/agents/api'; +import { agentsCreateAgent, agentsGetAgent } from '@nemo/sdk/generated/agents/api'; import { filesCreateFileset, filesDeleteFileset, @@ -10,6 +10,7 @@ import { } from '@nemo/sdk/generated/platform/api'; import { AgentSpecFilesetConflictError, + AgentSpecFilesetOrphanError, createAgentFromUpload, } from '@studio/api/agents/useCreateAgentFromUpload'; import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; @@ -17,7 +18,7 @@ import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/Upl vi.mock('@nemo/sdk/generated/agents/api', async (importOriginal) => ({ ...(await importOriginal()), agentsCreateAgent: vi.fn(), - agentsDeleteAgent: vi.fn(), + agentsGetAgent: vi.fn(), })); vi.mock('@nemo/sdk/generated/platform/api', async (importOriginal) => ({ @@ -42,13 +43,19 @@ const entries = (): UploadAgentEntry[] => [ const params = () => ({ workspace: 'ws', name: 'calc', entries: entries() }); +const filesetMissing = () => vi.mocked(filesRetrieveFileset).mockRejectedValue(new Error('404')); +const filesetExists = () => + vi.mocked(filesRetrieveFileset).mockResolvedValue({ name: 'calc-spec' } as never); +const agentMissing = () => vi.mocked(agentsGetAgent).mockRejectedValue(new Error('404')); +const agentExists = () => vi.mocked(agentsGetAgent).mockResolvedValue({ name: 'calc' } as never); + beforeEach(() => { - vi.mocked(filesRetrieveFileset).mockRejectedValue(new Error('404')); + filesetMissing(); + agentMissing(); vi.mocked(filesCreateFileset).mockResolvedValue({ name: 'calc-spec' } as never); vi.mocked(filesUploadFile).mockResolvedValue({ path: 'agent.yaml' } as never); vi.mocked(filesDeleteFileset).mockResolvedValue(undefined as never); vi.mocked(agentsCreateAgent).mockResolvedValue({ name: 'calc' } as never); - vi.mocked(agentsDeleteAgent).mockResolvedValue(undefined as never); }); afterEach(() => { @@ -56,46 +63,92 @@ afterEach(() => { }); describe('createAgentFromUpload', () => { - it('creates the agent, then uploads every file into the {agent}-spec fileset', async () => { + it('uploads every file before creating the agent', async () => { + const order: string[] = []; + vi.mocked(filesUploadFile).mockImplementation(async (_ws, _fs, path) => { + order.push(`upload:${path}`); + return { path } as never; + }); + vi.mocked(agentsCreateAgent).mockImplementation(async () => { + order.push('createAgent'); + return { name: 'calc' } as never; + }); + await createAgentFromUpload(params()); + expect(order).toEqual(['upload:agent.yaml', 'upload:mcps/calculator.py', 'createAgent']); expect(agentsCreateAgent).toHaveBeenCalledWith('ws', { name: 'calc', description: 'Adds numbers', config: expect.objectContaining({ config_format: 'nemo-agents-spec-v1' }), config_format: 'nemo-agents-spec-v1', }); - expect(filesCreateFileset).toHaveBeenCalledWith('ws', expect.objectContaining({ name: 'calc-spec' })); - expect(vi.mocked(filesUploadFile).mock.calls.map((call) => [call[1], call[2]])).toEqual([ - ['calc-spec', 'agent.yaml'], - ['calc-spec', 'mcps/calculator.py'], - ]); }); - it('refuses to touch an existing spec fileset', async () => { - vi.mocked(filesRetrieveFileset).mockResolvedValue({ name: 'calc-spec' } as never); + it('refuses a fileset that an existing agent owns', async () => { + filesetExists(); + agentExists(); await expect(createAgentFromUpload(params())).rejects.toThrow(AgentSpecFilesetConflictError); - expect(agentsCreateAgent).not.toHaveBeenCalled(); expect(filesCreateFileset).not.toHaveBeenCalled(); + expect(filesDeleteFileset).not.toHaveBeenCalled(); + }); + + it('asks before replacing a fileset that no agent owns', async () => { + filesetExists(); + + await expect(createAgentFromUpload(params())).rejects.toThrow(AgentSpecFilesetOrphanError); + expect(filesDeleteFileset).not.toHaveBeenCalled(); + expect(agentsCreateAgent).not.toHaveBeenCalled(); }); - it('deletes the agent and the fileset when an upload fails', async () => { + it('replaces an orphaned fileset once confirmed', async () => { + filesetExists(); + + await createAgentFromUpload({ ...params(), replaceOrphanedFileset: true }); + + expect(filesDeleteFileset).toHaveBeenCalledWith('ws', 'calc-spec'); + expect(filesCreateFileset).toHaveBeenCalledWith( + 'ws', + expect.objectContaining({ name: 'calc-spec' }) + ); + expect(agentsCreateAgent).toHaveBeenCalled(); + }); + + it('does not replace an owned fileset even when confirmed', async () => { + filesetExists(); + agentExists(); + + await expect( + createAgentFromUpload({ ...params(), replaceOrphanedFileset: true }) + ).rejects.toThrow(AgentSpecFilesetConflictError); + expect(filesDeleteFileset).not.toHaveBeenCalled(); + }); + + it('deletes the fileset when an upload fails', async () => { vi.mocked(filesUploadFile) .mockResolvedValueOnce({ path: 'agent.yaml' } as never) .mockRejectedValueOnce(new Error('network down')); await expect(createAgentFromUpload(params())).rejects.toThrow('network down'); - expect(agentsDeleteAgent).toHaveBeenCalledWith('ws', 'calc'); expect(filesDeleteFileset).toHaveBeenCalledWith('ws', 'calc-spec'); }); - it('rejects a non-Fabric config before creating anything', async () => { + it('deletes the fileset when creating the agent fails', async () => { + vi.mocked(agentsCreateAgent).mockRejectedValue(new Error('409 conflict')); + + await expect(createAgentFromUpload(params())).rejects.toThrow('409 conflict'); + expect(filesDeleteFileset).toHaveBeenCalledWith('ws', 'calc-spec'); + }); + + it('rejects a non-Fabric config before touching anything', async () => { const natEntries = [entryFor('agent.yaml', 'config_format: nat-workflow-v1\n')]; await expect( createAgentFromUpload({ workspace: 'ws', name: 'calc', entries: natEntries }) ).rejects.toThrow(/config_format/); + expect(filesRetrieveFileset).not.toHaveBeenCalled(); + expect(filesCreateFileset).not.toHaveBeenCalled(); expect(agentsCreateAgent).not.toHaveBeenCalled(); }); }); diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts index c311819c4a..103c0a7964 100644 --- a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { agentsCreateAgent, agentsDeleteAgent } from '@nemo/sdk/generated/agents/api'; +import { agentsCreateAgent, agentsGetAgent } from '@nemo/sdk/generated/agents/api'; import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; import { filesCreateFileset, @@ -22,38 +22,43 @@ export interface CreateAgentFromUploadParams { workspace: string; name: string; entries: UploadAgentEntry[]; + replaceOrphanedFileset?: boolean; } +/** An agent of this name already exists; its spec fileset is not ours to take. */ export class AgentSpecFilesetConflictError extends Error { constructor(public readonly filesetName: string) { super( - `A fileset named "${filesetName}" already exists. It holds the spec for an agent of this name — possibly one that was deleted, since deleting an agent leaves its fileset behind. Delete it with \`nemo files filesets delete ${filesetName}\`, or choose a different name.` + `An agent named "${filesetName.replace(/-spec$/, '')}" already owns the fileset "${filesetName}". Choose a different name.` ); } } -// Two phases: no endpoint takes config and files together. Creating the agent first -// reserves the name. Deleting the fileset on rollback is safe only because an existing -// one is refused up front, so anything in it was put there by this flow. +/** A spec fileset with no agent behind it — an abandoned upload, or an agent since deleted. */ +export class AgentSpecFilesetOrphanError extends Error { + constructor(public readonly filesetName: string) { + super( + `A fileset named "${filesetName}" already exists, but no agent owns it — an upload that did not finish, or an agent that was deleted, since deleting an agent leaves its fileset behind. Replacing it discards its current contents.` + ); + } +} + +// Files first: the fileset reserves the name, and a create-time validation that needs a +// base_dir can only see files that are already uploaded. Deleting the fileset on rollback +// is safe because an existing one is either refused or replaced deliberately above. export const createAgentFromUpload = async ({ workspace, name, entries, + replaceOrphanedFileset = false, }: CreateAgentFromUploadParams): Promise => { const filesetName = agentSpecFilesetName(name); - await assertFilesetAvailable(workspace, filesetName); - const configEntry = entries.find((entry) => entry.path === AGENT_CONFIG_FILENAME); if (!configEntry) throw new Error(`No ${AGENT_CONFIG_FILENAME} in the selected directory.`); const config = parseAgentConfig(await configEntry.file.text()); - const agent = await agentsCreateAgent(workspace, { - name, - description: typeof config.description === 'string' ? config.description : '', - config, - config_format: FABRIC_CONFIG_FORMAT, - }); + await claimFileset(workspace, name, filesetName, replaceOrphanedFileset); try { await filesCreateFileset(workspace, { @@ -61,21 +66,48 @@ export const createAgentFromUpload = async ({ description: `Agent spec for ${name}`, }); await uploadEntries(workspace, filesetName, entries); + + return await agentsCreateAgent(workspace, { + name, + description: typeof config.description === 'string' ? config.description : '', + config, + config_format: FABRIC_CONFIG_FORMAT, + }); } catch (error) { - await rollback(workspace, name, filesetName); + await rollback(workspace, filesetName); throw error; } - - return agent; }; -const assertFilesetAvailable = async (workspace: string, filesetName: string): Promise => { +const claimFileset = async ( + workspace: string, + agentName: string, + filesetName: string, + replaceOrphanedFileset: boolean +): Promise => { try { await filesRetrieveFileset(workspace, filesetName); } catch { return; } - throw new AgentSpecFilesetConflictError(filesetName); + + if (await agentExists(workspace, agentName)) { + throw new AgentSpecFilesetConflictError(filesetName); + } + if (!replaceOrphanedFileset) { + throw new AgentSpecFilesetOrphanError(filesetName); + } + + await filesDeleteFileset(workspace, filesetName); +}; + +const agentExists = async (workspace: string, agentName: string): Promise => { + try { + await agentsGetAgent(workspace, agentName); + return true; + } catch { + return false; + } }; const uploadEntries = async ( @@ -89,15 +121,8 @@ const uploadEntries = async ( } }; -const rollback = async ( - workspace: string, - agentName: string, - filesetName: string -): Promise => { - await Promise.allSettled([ - agentsDeleteAgent(workspace, agentName), - filesDeleteFileset(workspace, filesetName), - ]); +const rollback = async (workspace: string, filesetName: string): Promise => { + await filesDeleteFileset(workspace, filesetName).catch(() => undefined); }; export type UseCreateAgentFromUploadOptions = Omit< diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx index cf29012353..8298cfb7b9 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -8,7 +8,10 @@ import { FormModal } from '@nemo/common/src/components/FormModal'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; import { getAgentsListAgentsQueryKey } from '@nemo/sdk/generated/agents/api'; import { Button, Stack, Text } from '@nvidia/foundations-react-core'; -import { useCreateAgentFromUpload } from '@studio/api/agents/useCreateAgentFromUpload'; +import { + AgentSpecFilesetOrphanError, + useCreateAgentFromUpload, +} from '@studio/api/agents/useCreateAgentFromUpload'; import { AGENT_CONFIG_FILENAME, agentNameFromConfig, @@ -27,7 +30,7 @@ import type { import { getAgentDetailRoute } from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; import { type ChangeEventHandler, type FC, useEffect, useRef, useState } from 'react'; -import { type SubmitHandler, useForm } from 'react-hook-form'; +import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { useNavigate } from 'react-router'; export const UploadAgentModal: FC = ({ open, onClose, workspace }) => { @@ -39,6 +42,7 @@ export const UploadAgentModal: FC = ({ open, onClose, wor const [entries, setEntries] = useState([]); const [directoryName, setDirectoryName] = useState(''); const [selectionError, setSelectionError] = useState(undefined); + const [replaceOrphan, setReplaceOrphan] = useState(false); // webkitdirectory is absent from React's input attribute types. useEffect(() => { @@ -72,12 +76,20 @@ export const UploadAgentModal: FC = ({ open, onClose, wor mode: 'onChange', }); + const watchedName = useWatch({ control, name: 'name' }); + + // The armed replace targets one fileset; a different name must re-confirm. + useEffect(() => { + setReplaceOrphan(false); + }, [watchedName]); + const resetAndClose = () => { resetMutation(); resetForm({ name: '' }); setEntries([]); setDirectoryName(''); setSelectionError(undefined); + setReplaceOrphan(false); onClose(); }; @@ -123,9 +135,15 @@ export const UploadAgentModal: FC = ({ open, onClose, wor const onSubmit: SubmitHandler = async (formData) => { try { - await createAgent({ workspace, name: formData.name.trim(), entries }); - } catch { - // surfaced via errorText + await createAgent({ + workspace, + name: formData.name.trim(), + entries, + replaceOrphanedFileset: replaceOrphan, + }); + } catch (error) { + // An orphaned fileset is recoverable, so the next submit replaces it. + setReplaceOrphan(error instanceof AgentSpecFilesetOrphanError); } }; @@ -140,7 +158,7 @@ export const UploadAgentModal: FC = ({ open, onClose, wor onClose={resetAndClose} title="Upload Agent" instruction={`Select a directory containing ${AGENT_CONFIG_FILENAME}. Its skills, MCP servers, and prompts are uploaded with it.`} - submitButtonText="Create" + submitButtonText={replaceOrphan ? 'Replace and create' : 'Create'} onSubmit={handleSubmit(onSubmit)} disabled={isPending} loading={isPending} From e7434988c081d3d57bef6a667ea40c2a7ced985b Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 20 Aug 2026 14:51:57 -0700 Subject: [PATCH 05/12] test(studio): cover the upload modal end to end The flow tests assert that createAgentFromUpload raises the right error; they cannot see whether the modal renders it. That gap hid a real bug, where getErrorMessage's fallback replaced the conflict message with "Failed to create agent" and the user lost both the fileset name and the way out. Drive the modal instead: pick a directory, submit, and assert on what the dialog shows. Reverting the getErrorMessage fix fails two of these. The hidden file input carries a test id because a directory picker cannot be reached by role, and reaching into the DOM trips testing-library/no-node-access. Signed-off-by: mschwab --- .../UploadAgentModal/index.test.tsx | 172 ++++++++++++++++++ .../UploadAgentModal/index.tsx | 9 +- 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx new file mode 100644 index 0000000000..da4dff4c2e --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 { UploadAgentModal } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal'; +import { getAgentsListRoute } from '@studio/routes/utils'; +import { renderRoute, screen, waitFor } from '@studio/tests/util/render'; +import { fireEvent, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; + +const workspace = workspace1.workspace; +const FILESETS_URL = `${PLATFORM_BASE_URL}/apis/files/v2/workspaces/:workspace/filesets`; +const FILESET_URL = `${FILESETS_URL}/:name`; +const UPLOAD_URL = `${FILESET_URL}/-/*`; +const AGENTS_URL = `${PLATFORM_BASE_URL}/apis/agents/v2/workspaces/:workspace/agents`; +const AGENT_URL = `${AGENTS_URL}/:name`; + +const FABRIC_YAML = `config_format: nemo-agents-spec-v1 +name: calc +description: Adds numbers +`; + +const makeFile = (relativePath: string, contents: string): File => { + const file = new File([contents], relativePath.split('/').pop() ?? relativePath, { + type: 'text/plain', + }); + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }); + return file; +}; + +const DEFAULT_FILES = [ + makeFile('calc-agent/agent.yaml', FABRIC_YAML), + makeFile('calc-agent/mcps/calculator.py', 'print(1)\n'), +]; + +interface Scenario { + filesetExists?: boolean; + agentExists?: boolean; +} + +const mockPlatform = ({ filesetExists = false, agentExists = false }: Scenario = {}) => { + const uploaded: string[] = []; + const created: { name?: string }[] = []; + + server.use( + http.get(FILESET_URL, ({ params }) => + filesetExists + ? HttpResponse.json({ name: params['name'], workspace }) + : HttpResponse.json({ detail: 'not found' }, { status: 404 }) + ), + http.get(AGENT_URL, ({ params }) => + agentExists + ? HttpResponse.json({ name: params['name'], workspace }) + : HttpResponse.json({ detail: 'not found' }, { status: 404 }) + ), + http.delete(FILESET_URL, () => HttpResponse.json({ name: 'deleted' })), + http.post(FILESETS_URL, async ({ request }) => + HttpResponse.json(await request.json()) + ), + http.put(UPLOAD_URL, ({ request }) => { + uploaded.push(decodeURIComponent(new URL(request.url).pathname.split('/-/')[1] ?? '')); + return HttpResponse.json({ path: 'ok' }); + }), + http.post(AGENTS_URL, async ({ request }) => { + const body = (await request.json()) as { name?: string }; + created.push(body); + return HttpResponse.json({ ...body, workspace }, { status: 201 }); + }) + ); + + return { uploaded, created }; +}; + +const renderModal = () => + renderRoute(undefined, { + history: getAgentsListRoute(workspace), + routes: [ + { + path: ROUTES.workspace.agentsList, + element: , + }, + { path: ROUTES.workspace.agentDetail, element:
Agent detail page
}, + ], + }); + +const pickDirectory = (dialog: HTMLElement, files: File[] = DEFAULT_FILES) => { + fireEvent.change(within(dialog).getByTestId('agent-directory-input'), { target: { files } }); +}; + +const submit = async (dialog: HTMLElement, user: ReturnType) => { + await user.click(within(dialog).getByRole('button', { name: /^(Create|Replace and create)$/ })); +}; + +describe('UploadAgentModal', () => { + it('uploads the picked directory, then creates the agent', async () => { + const user = userEvent.setup(); + const { uploaded, created } = mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + + await submit(dialog, user); + + await waitFor(() => expect(created).toHaveLength(1)); + expect(uploaded).toEqual(['agent.yaml', 'mcps/calculator.py']); + expect(created[0]?.name).toBe('calc'); + }); + + it('shows why an owned name is refused instead of a generic failure', async () => { + const user = userEvent.setup(); + const { created } = mockPlatform({ filesetExists: true, agentExists: true }); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + + await submit(dialog, user); + + expect(await within(dialog).findByText(/already owns the fileset/)).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument(); + expect(created).toHaveLength(0); + }); + + it('offers to replace an orphaned fileset, and replaces it on the next submit', async () => { + const user = userEvent.setup(); + const { created } = mockPlatform({ filesetExists: true }); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + + await submit(dialog, user); + expect(await within(dialog).findByText(/no agent owns it/)).toBeInTheDocument(); + + const replace = await within(dialog).findByRole('button', { name: 'Replace and create' }); + await user.click(replace); + + await waitFor(() => expect(created).toHaveLength(1)); + }); + + it('rejects a directory with no agent.yaml at the top level', async () => { + mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog, [makeFile('calc-agent/mcps/calculator.py', 'print(1)\n')]); + + expect(await within(dialog).findByText(/No agent\.yaml/)).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('rejects a directory holding a file that is not text', async () => { + mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog, [ + makeFile('calc-agent/agent.yaml', FABRIC_YAML), + new File([new Uint8Array([0xff, 0xfe, 0x00])], 'logo.bin'), + ]); + + expect(await within(dialog).findByText(/is not a text file/)).toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx index 8298cfb7b9..0cc726abc3 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -166,7 +166,14 @@ export const UploadAgentModal: FC = ({ open, onClose, wor errorText={errorMessage} > - + From 3e47de40f7d5c881a8ef8ab21207f7f6054202c3 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 20 Aug 2026 15:26:31 -0700 Subject: [PATCH 06/12] feat(studio): match the integrate-agent modal to its design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design (Figma 221-24009) frames upload as one of three tabs in an "Integrate an agent with NeMo Platform" modal, not a standalone Upload Agent dialog. Restructure to match: retitle, add the tab shell, and move the directory picker under "Upload agent configuration". The other two tabs are copy-to-clipboard blocks. The coding-agent prompt is the designed string. The CLI command is derived from what `nemo agents create` actually takes and tracks the name field, since no node in the file specifies that tab's content — worth a designer's confirmation. Swap the hand-rolled button for KUI's Upload, which is the dropzone the design draws and which this should have used from the start. Its input is kept so webkitRelativePath survives: it is what turns a picked directory into nested paths like mcps/calculator.py, and losing it would strip an agent of its skills and MCP servers. webkitdirectory is now applied through a callback ref, because switching tabs unmounts the input and an effect keyed on `open` left it a plain file picker on the way back. Cancel and Create stay. The design shows Back and Close, which belong to a two-step wizard whose first step chooses between traces and integration; that step is out of scope here, and dropping Create would strip the name field and the orphaned-fileset replace flow with nothing designed to replace them. Signed-off-by: mschwab --- .../AgentsListRoute/UploadAgentModal/const.ts | 8 ++ .../UploadAgentModal/index.test.tsx | 30 +++++ .../UploadAgentModal/index.tsx | 116 +++++++++++++----- .../routes/agents/AgentsListRoute/index.tsx | 2 +- 4 files changed, 123 insertions(+), 33 deletions(-) diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts index 1441996576..b3768bf364 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts @@ -6,6 +6,14 @@ import YAML from 'yaml'; import { z } from 'zod'; export const AGENT_CONFIG_FILENAME = 'agent.yaml'; + +/** Copy shown on the coding-agent tab, per the Figma design. */ +export const AGENT_INTEGRATION_PROMPT = + 'Integrate my agent with NeMo Platform using the agent integration skill and connect it to platform.'; + +/** The command the CLI tab offers; mirrors what `nemo agents create` takes. */ +export const agentCreateCliCommand = (agentName?: string): string => + `nemo agents create \\\n --name ${agentName?.trim() || ''} \\\n --agent-config ./${AGENT_CONFIG_FILENAME}`; export const FABRIC_CONFIG_FORMAT = 'nemo-agents-spec-v1'; // Container staging skips this file, so its bytes never reach a deployment. diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx index da4dff4c2e..113293235a 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx @@ -170,3 +170,33 @@ describe('UploadAgentModal', () => { expect(await within(dialog).findByText(/is not a text file/)).toBeInTheDocument(); }); }); + +describe('UploadAgentModal tabs', () => { + it('offers the designed coding-agent prompt', async () => { + const user = userEvent.setup(); + mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + await user.click(within(dialog).getByRole('tab', { name: 'Coding agent prompt' })); + + expect( + await within(dialog).findByText(/Integrate my agent with NeMo Platform/) + ).toBeInTheDocument(); + }); + + it('shows a CLI command carrying the entered name', async () => { + const user = userEvent.setup(); + mockPlatform(); + + renderModal(); + const dialog = await screen.findByRole('dialog'); + pickDirectory(dialog); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + + await user.click(within(dialog).getByRole('tab', { name: 'CLI command' })); + + await waitFor(() => expect(dialog).toHaveTextContent('nemo agents create')); + expect(dialog).toHaveTextContent('--name calc'); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx index 0cc726abc3..ab417f3a2b 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -7,13 +7,26 @@ import { ControlledTextInput } from '@nemo/common/src/components/form/Controlled import { FormModal } from '@nemo/common/src/components/FormModal'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; import { getAgentsListAgentsQueryKey } from '@nemo/sdk/generated/agents/api'; -import { Button, Stack, Text } from '@nvidia/foundations-react-core'; +import { + CodeSnippet, + Stack, + TabsContent, + TabsList, + TabsRoot, + TabsTrigger, + Text, + UploadInputElement, + UploadRoot, + UploadTrigger, +} from '@nvidia/foundations-react-core'; import { AgentSpecFilesetOrphanError, useCreateAgentFromUpload, } from '@studio/api/agents/useCreateAgentFromUpload'; import { AGENT_CONFIG_FILENAME, + AGENT_INTEGRATION_PROMPT, + agentCreateCliCommand, agentNameFromConfig, collectAgentEntries, findNonUtf8Path, @@ -29,7 +42,7 @@ import type { } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; import { getAgentDetailRoute } from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; -import { type ChangeEventHandler, type FC, useEffect, useRef, useState } from 'react'; +import { type ChangeEventHandler, type FC, useCallback, useEffect, useRef, useState } from 'react'; import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { useNavigate } from 'react-router'; @@ -39,16 +52,16 @@ export const UploadAgentModal: FC = ({ open, onClose, wor const queryClient = useQueryClient(); const inputRef = useRef(null); + const setDirectoryInput = useCallback((node: HTMLInputElement | null) => { + inputRef.current = node; + // webkitdirectory is absent from React's input attribute types. + node?.setAttribute('webkitdirectory', ''); + }, []); const [entries, setEntries] = useState([]); const [directoryName, setDirectoryName] = useState(''); const [selectionError, setSelectionError] = useState(undefined); const [replaceOrphan, setReplaceOrphan] = useState(false); - // webkitdirectory is absent from React's input attribute types. - useEffect(() => { - inputRef.current?.setAttribute('webkitdirectory', ''); - }, [open]); - const { mutateAsync: createAgent, error: createError, @@ -156,8 +169,9 @@ export const UploadAgentModal: FC = ({ open, onClose, wor = ({ open, onClose, wor submitDisabled={entries.length === 0} errorText={errorMessage} > - - - - {entries.length > 0 ? ( - - {`${directoryName} — ${entries.length} files, ${Math.max(1, Math.round(totalEntryBytes(entries) / 1000))} KB`} - - ) : null} - - + + + Coding agent prompt + CLI command + Upload agent configuration + + + + + Agent prompt + + + + + + + CLI command + + + + + + + Select agent config files + + + + + + {entries.length > 0 ? ( + + {`${directoryName} — ${entries.length} files, ${Math.max(1, Math.round(totalEntryBytes(entries) / 1000))} KB`} + + ) : null} + + + + ); }; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx index 8b389a3f51..f97362ecea 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx @@ -68,7 +68,7 @@ export const AgentsListRoute: FC = () => { slotActions={