From 7ea04ae6147ffdb5cfe3c10336aadc4a0b4ab332 Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 13 Mar 2026 14:56:46 -0700 Subject: [PATCH 1/9] Add file operations for hosted agent sessions (#7132) Add 'azd ai agent files' command group with upload, download, list, and remove subcommands for managing session-scoped files on hosted agent sandboxes. This enables debugging, seeding data, and agent setup. New commands: - files upload --path - files download [-o ] - files list [remote-path] [--output json|table] - files remove [--recursive] All commands require --name, --version, and --session flags. Uses the vnext API version (2025-11-15-preview) for session-scoped endpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/files.go | 432 ++++++++++++++++++ .../internal/cmd/files_test.go | 188 ++++++++ .../azure.ai.agents/internal/cmd/root.go | 1 + .../internal/exterrors/codes.go | 7 + .../internal/pkg/agents/agent_api/models.go | 14 + .../pkg/agents/agent_api/operations.go | 202 ++++++++ 6 files changed, 844 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/files.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go new file mode 100644 index 00000000000..90981ef7ab7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -0,0 +1,432 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "text/tabwriter" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// filesFlags holds the common flags shared by all file subcommands. +type filesFlags struct { + accountName string + projectName string + name string + version string + session string +} + +func newFilesCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "files ", + Short: "Manage files in a hosted agent session.", + Long: `Manage files in a hosted agent session. + +Upload, download, list, and remove files in the session-scoped filesystem +of a hosted agent. This is useful for debugging, seeding data, and agent setup.`, + } + + cmd.AddCommand(newFilesUploadCommand()) + cmd.AddCommand(newFilesDownloadCommand()) + cmd.AddCommand(newFilesListCommand()) + cmd.AddCommand(newFilesRemoveCommand()) + + return cmd +} + +// addFilesFlags registers the common flags on a cobra command. +func addFilesFlags(cmd *cobra.Command, flags *filesFlags) { + cmd.Flags().StringVarP(&flags.accountName, "account-name", "a", "", "Cognitive Services account name") + cmd.Flags().StringVarP(&flags.projectName, "project-name", "p", "", "AI Foundry project name") + cmd.Flags().StringVarP(&flags.name, "name", "n", "", "Name of the hosted agent (required)") + cmd.Flags().StringVarP(&flags.version, "version", "v", "", "Version of the hosted agent (required)") + cmd.Flags().StringVarP(&flags.session, "session", "s", "", "Session ID (required)") + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("version") + _ = cmd.MarkFlagRequired("session") +} + +// --- upload --- + +type filesUploadFlags struct { + filesFlags + localPath string +} + +// FilesUploadAction handles uploading a file to a session. +type FilesUploadAction struct { + *AgentContext + flags *filesUploadFlags + remotePath string +} + +func newFilesUploadCommand() *cobra.Command { + flags := &filesUploadFlags{} + + cmd := &cobra.Command{ + Use: "upload ", + Short: "Upload a file to a hosted agent session.", + Long: `Upload a file to a hosted agent session. + +Reads a local file and uploads it to the specified remote path +in the session's filesystem.`, + Example: ` # Upload a file to the session + azd ai agent files upload /data/input.csv --path ./input.csv -n my-agent -v 1 -s `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + if err != nil { + return err + } + + action := &FilesUploadAction{ + AgentContext: agentContext, + flags: flags, + remotePath: args[0], + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVar(&flags.localPath, "path", "", "Local file path to upload (required)") + _ = cmd.MarkFlagRequired("path") + + return cmd +} + +// Run executes the upload action. +func (a *FilesUploadAction) Run(ctx context.Context) error { + //nolint:gosec // G304: localPath is provided by the user via CLI flag + file, err := os.Open(a.flags.localPath) + if err != nil { + return fmt.Errorf("failed to open local file %q: %w", a.flags.localPath, err) + } + defer file.Close() + + agentClient, err := a.NewClient() + if err != nil { + return err + } + + err = agentClient.UploadSessionFile( + ctx, + a.Name, + a.Version, + a.flags.session, + a.remotePath, + DefaultVNextAgentAPIVersion, + file, + ) + if err != nil { + return fmt.Errorf("failed to upload file: %w", err) + } + + fmt.Printf("Uploaded %s → %s\n", a.flags.localPath, a.remotePath) + return nil +} + +// --- download --- + +type filesDownloadFlags struct { + filesFlags + outputPath string +} + +// FilesDownloadAction handles downloading a file from a session. +type FilesDownloadAction struct { + *AgentContext + flags *filesDownloadFlags + remotePath string +} + +func newFilesDownloadCommand() *cobra.Command { + flags := &filesDownloadFlags{} + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download a file from a hosted agent session.", + Long: `Download a file from a hosted agent session. + +Downloads a file from the specified remote path in the session's +filesystem and saves it locally.`, + Example: ` # Download a file from the session + azd ai agent files download /data/output.csv -o ./output.csv -n my-agent -v 1 -s + + # Download to current directory (uses remote filename) + azd ai agent files download /data/output.csv -n my-agent -v 1 -s `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + if err != nil { + return err + } + + action := &FilesDownloadAction{ + AgentContext: agentContext, + flags: flags, + remotePath: args[0], + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVarP(&flags.outputPath, "output", "o", "", "Local output file path (defaults to remote filename)") + + return cmd +} + +// Run executes the download action. +func (a *FilesDownloadAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + body, err := agentClient.DownloadSessionFile( + ctx, + a.Name, + a.Version, + a.flags.session, + a.remotePath, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to download file: %w", err) + } + defer body.Close() + + outputPath := a.flags.outputPath + if outputPath == "" { + outputPath = filepath.Base(a.remotePath) + } + + //nolint:gosec // G304: outputPath is provided by the user via CLI flag + outFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file %q: %w", outputPath, err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, body); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + fmt.Printf("Downloaded %s → %s\n", a.remotePath, outputPath) + return nil +} + +// --- list --- + +type filesListFlags struct { + filesFlags + output string +} + +// FilesListAction handles listing files in a session. +type FilesListAction struct { + *AgentContext + flags *filesListFlags + remotePath string +} + +func newFilesListCommand() *cobra.Command { + flags := &filesListFlags{} + + cmd := &cobra.Command{ + Use: "list [remote-path]", + Short: "List files in a hosted agent session.", + Long: `List files in a hosted agent session. + +Lists files and directories at the specified path in the session's filesystem. +When no path is provided, lists the root directory.`, + Example: ` # List files in the root directory + azd ai agent files list -n my-agent -v 1 -s + + # List files in a specific directory + azd ai agent files list /data -n my-agent -v 1 -s + + # List files in table format + azd ai agent files list /data -n my-agent -v 1 -s --output table`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + if err != nil { + return err + } + + remotePath := "" + if len(args) > 0 { + remotePath = args[0] + } + + action := &FilesListAction{ + AgentContext: agentContext, + flags: flags, + remotePath: remotePath, + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVar(&flags.output, "output", "json", "Output format (json or table)") + + return cmd +} + +// Run executes the list action. +func (a *FilesListAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + fileList, err := agentClient.ListSessionFiles( + ctx, + a.Name, + a.Version, + a.flags.session, + a.remotePath, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to list files: %w", err) + } + + switch a.flags.output { + case "table": + return printFileListTable(fileList) + default: + return printFileListJSON(fileList) + } +} + +func printFileListJSON(fileList *agent_api.SessionFileList) error { + jsonBytes, err := json.MarshalIndent(fileList, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal file list to JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} + +func printFileListTable(fileList *agent_api.SessionFileList) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tPATH\tTYPE\tSIZE\tLAST MODIFIED") + fmt.Fprintln(w, "----\t----\t----\t----\t-------------") + + for _, f := range fileList.Files { + fileType := "file" + if f.IsDirectory { + fileType = "dir" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", f.Name, f.Path, fileType, f.Size, f.LastModified) + } + + return w.Flush() +} + +// --- remove --- + +type filesRemoveFlags struct { + filesFlags + recursive bool +} + +// FilesRemoveAction handles removing a file or directory from a session. +type FilesRemoveAction struct { + *AgentContext + flags *filesRemoveFlags + remotePath string +} + +func newFilesRemoveCommand() *cobra.Command { + flags := &filesRemoveFlags{} + + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a file or directory from a hosted agent session.", + Long: `Remove a file or directory from a hosted agent session. + +Removes the specified file or directory from the session's filesystem. +Use --recursive to remove directories and their contents.`, + Example: ` # Remove a file + azd ai agent files remove /data/old-file.csv -n my-agent -v 1 -s + + # Remove a directory recursively + azd ai agent files remove /data/temp --recursive -n my-agent -v 1 -s `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + if err != nil { + return err + } + + action := &FilesRemoveAction{ + AgentContext: agentContext, + flags: flags, + remotePath: args[0], + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().BoolVar(&flags.recursive, "recursive", false, "Recursively remove directories and their contents") + + return cmd +} + +// Run executes the remove action. +func (a *FilesRemoveAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + err = agentClient.RemoveSessionFile( + ctx, + a.Name, + a.Version, + a.flags.session, + a.remotePath, + a.flags.recursive, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } + + fmt.Printf("Removed %s\n", a.remotePath) + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go new file mode 100644 index 00000000000..a5936535210 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilesCommand_HasSubcommands(t *testing.T) { + cmd := newFilesCommand() + + subcommands := cmd.Commands() + names := make([]string, len(subcommands)) + for i, c := range subcommands { + names[i] = c.Name() + } + + assert.Contains(t, names, "upload") + assert.Contains(t, names, "download") + assert.Contains(t, names, "list") + assert.Contains(t, names, "remove") +} + +func TestFilesUploadCommand_RequiredFlags(t *testing.T) { + cmd := newFilesUploadCommand() + + // No flags and no args should fail + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestFilesUploadCommand_MissingName(t *testing.T) { + cmd := newFilesUploadCommand() + + cmd.SetArgs([]string{"/remote/path", "--path", "local.txt", "--version", "1", "--session", "abc"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "name") +} + +func TestFilesUploadCommand_MissingVersion(t *testing.T) { + cmd := newFilesUploadCommand() + + cmd.SetArgs([]string{"/remote/path", "--path", "local.txt", "--name", "agent", "--session", "abc"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "version") +} + +func TestFilesUploadCommand_MissingSession(t *testing.T) { + cmd := newFilesUploadCommand() + + cmd.SetArgs([]string{"/remote/path", "--path", "local.txt", "--name", "agent", "--version", "1"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "session") +} + +func TestFilesUploadCommand_MissingPath(t *testing.T) { + cmd := newFilesUploadCommand() + + cmd.SetArgs([]string{"/remote/path", "--name", "agent", "--version", "1", "--session", "abc"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "path") +} + +func TestFilesDownloadCommand_RequiredFlags(t *testing.T) { + cmd := newFilesDownloadCommand() + + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestFilesDownloadCommand_DefaultOutputPath(t *testing.T) { + cmd := newFilesDownloadCommand() + + output, _ := cmd.Flags().GetString("output") + assert.Equal(t, "", output, "output should default to empty (uses remote filename)") +} + +func TestFilesListCommand_RequiredFlags(t *testing.T) { + cmd := newFilesListCommand() + + // No flags should fail due to missing required flags + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestFilesListCommand_DefaultOutputFormat(t *testing.T) { + cmd := newFilesListCommand() + + output, _ := cmd.Flags().GetString("output") + assert.Equal(t, "json", output) +} + +func TestFilesListCommand_OptionalRemotePath(t *testing.T) { + cmd := newFilesListCommand() + + // Verify the command accepts 0 or 1 args + assert.NotNil(t, cmd.Args) +} + +func TestFilesRemoveCommand_RequiredFlags(t *testing.T) { + cmd := newFilesRemoveCommand() + + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestFilesRemoveCommand_RecursiveDefault(t *testing.T) { + cmd := newFilesRemoveCommand() + + recursive, _ := cmd.Flags().GetBool("recursive") + assert.False(t, recursive, "recursive should default to false") +} + +func TestPrintFileListJSON(t *testing.T) { + fileList := &agent_api.SessionFileList{ + Files: []agent_api.SessionFileInfo{ + { + Name: "test.txt", + Path: "/data/test.txt", + IsDirectory: false, + Size: 1024, + LastModified: "2025-01-01T00:00:00Z", + }, + { + Name: "subdir", + Path: "/data/subdir", + IsDirectory: true, + }, + }, + } + + err := printFileListJSON(fileList) + require.NoError(t, err) +} + +func TestPrintFileListTable(t *testing.T) { + fileList := &agent_api.SessionFileList{ + Files: []agent_api.SessionFileInfo{ + { + Name: "test.txt", + Path: "/data/test.txt", + IsDirectory: false, + Size: 1024, + LastModified: "2025-01-01T00:00:00Z", + }, + { + Name: "subdir", + Path: "/data/subdir", + IsDirectory: true, + }, + }, + } + + err := printFileListTable(fileList) + require.NoError(t, err) +} + +func TestPrintFileListJSON_Empty(t *testing.T) { + fileList := &agent_api.SessionFileList{ + Files: []agent_api.SessionFileInfo{}, + } + + err := printFileListJSON(fileList) + require.NoError(t, err) +} + +func TestPrintFileListTable_Empty(t *testing.T) { + fileList := &agent_api.SessionFileList{ + Files: []agent_api.SessionFileInfo{}, + } + + err := printFileListTable(fileList) + require.NoError(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index 91fdfbf271e..27f6f8c7477 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -68,6 +68,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newMetadataCommand()) rootCmd.AddCommand(newShowCommand()) rootCmd.AddCommand(newMonitorCommand()) + rootCmd.AddCommand(newFilesCommand()) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 58c6cdad41f..beec107dafb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -66,6 +66,13 @@ const ( CodeModelResolutionFailed = "model_resolution_failed" ) +// Error codes for file operation errors. +const ( + CodeFileNotFound = "file_not_found" + CodeFileUploadFailed = "file_upload_failed" + CodeInvalidFilePath = "invalid_file_path" +) + // Error codes for internal errors. const ( CodeAzdClientFailed = "azd_client_failed" diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index 758b34ede50..3f7671e82de 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -682,3 +682,17 @@ type StructuredOutputDefinition struct { Schema map[string]any `json:"schema"` Strict *bool `json:"strict"` } + +// SessionFileInfo represents a file or directory entry in a session. +type SessionFileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + IsDirectory bool `json:"is_directory"` + Size int64 `json:"size,omitempty"` + LastModified string `json:"last_modified,omitempty"` +} + +// SessionFileList represents the response from listing session files. +type SessionFileList struct { + Files []SessionFileInfo `json:"files"` +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index ae189ca26c8..b1221e621fb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -981,3 +981,205 @@ func (c *AgentClient) GetAgentContainerOperation(ctx context.Context, agentName, return &operation, nil } + +// UploadSessionFile uploads a file to a session's filesystem. +// remotePath is the destination path on the session's filesystem. +// body is the file content to upload. +func (c *AgentClient) UploadSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, + body io.Reader, +) error { + u, err := url.Parse(c.endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + u.RawQuery = query.Encode() + + token, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://ai.azure.com/.default"}, + }) + if err != nil { + return fmt.Errorf("failed to get auth token: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token.Token) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("User-Agent", fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version)) + + httpClient := &http.Client{} + //nolint:gosec // request URL is built from trusted SDK endpoint + path components + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// DownloadSessionFile downloads a file from a session's filesystem. +// remotePath is the source path on the session's filesystem. +// Returns an io.ReadCloser with the file content; the caller must close it. +func (c *AgentClient) DownloadSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, +) (io.ReadCloser, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + u.RawQuery = query.Encode() + + token, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://ai.azure.com/.default"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to get auth token: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token.Token) + req.Header.Set("User-Agent", fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version)) + + httpClient := &http.Client{} + //nolint:gosec // request URL is built from trusted SDK endpoint + path components + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + _ = resp.Body.Close() + return nil, fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + return resp.Body, nil +} + +// ListSessionFiles lists files in a session's filesystem. +// remotePath is the directory path to list (empty string for root). +func (c *AgentClient) ListSessionFiles( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, +) (*SessionFileList, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files/list", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + if remotePath != "" { + query.Set("path", remotePath) + } + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodGet, u.String()) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var fileList SessionFileList + if err := json.Unmarshal(respBody, &fileList); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &fileList, nil +} + +// RemoveSessionFile removes a file or directory from a session's filesystem. +// remotePath is the path to remove. +// recursive controls whether to recursively remove directories. +func (c *AgentClient) RemoveSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath string, + recursive bool, + apiVersion string, +) error { + u, err := url.Parse(c.endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + query.Set("recursive", strconv.FormatBool(recursive)) + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodDelete, u.String()) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return runtime.NewResponseError(resp) + } + + return nil +} From 73131e3b0b07ccd92d019364898706c7ef1e19d4 Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 16 Mar 2026 10:24:12 -0700 Subject: [PATCH 2/9] PR comments Signed-off-by: trangevi --- .../pkg/agents/agent_api/operations.go | 49 ++++++------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index b1221e621fb..71a4826ac84 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -988,7 +988,7 @@ func (c *AgentClient) GetAgentContainerOperation(ctx context.Context, agentName, func (c *AgentClient) UploadSessionFile( ctx context.Context, agentName, agentVersion, sessionID, remotePath, apiVersion string, - body io.Reader, + body io.ReadSeeker, ) error { u, err := url.Parse(c.endpoint) if err != nil { @@ -1005,33 +1005,23 @@ func (c *AgentClient) UploadSessionFile( query.Set("path", remotePath) u.RawQuery = query.Encode() - token, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: []string{"https://ai.azure.com/.default"}, - }) - if err != nil { - return fmt.Errorf("failed to get auth token: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), body) + req, err := runtime.NewRequest(ctx, http.MethodPut, u.String()) if err != nil { return fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+token.Token) - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("User-Agent", fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version)) + if err := req.SetBody(streaming.NopCloser(body), "application/octet-stream"); err != nil { + return fmt.Errorf("failed to set request body: %w", err) + } - httpClient := &http.Client{} - //nolint:gosec // request URL is built from trusted SDK endpoint + path components - resp, err := httpClient.Do(req) + resp, err := c.pipeline.Do(req) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody)) + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return runtime.NewResponseError(resp) } return nil @@ -1059,32 +1049,21 @@ func (c *AgentClient) DownloadSessionFile( query.Set("path", remotePath) u.RawQuery = query.Encode() - token, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: []string{"https://ai.azure.com/.default"}, - }) - if err != nil { - return nil, fmt.Errorf("failed to get auth token: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + req, err := runtime.NewRequest(ctx, http.MethodGet, u.String()) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+token.Token) - req.Header.Set("User-Agent", fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version)) + runtime.SkipBodyDownload(req) - httpClient := &http.Client{} - //nolint:gosec // request URL is built from trusted SDK endpoint + path components - resp, err := httpClient.Do(req) + resp, err := c.pipeline.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - _ = resp.Body.Close() - return nil, fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(respBody)) + if !runtime.HasStatusCode(resp, http.StatusOK) { + defer resp.Body.Close() + return nil, runtime.NewResponseError(resp) } return resp.Body, nil From 170dfaf688dcdf9064544208f9572cfaf9b45ddb Mon Sep 17 00:00:00 2001 From: Travis Angevine Date: Mon, 16 Mar 2026 10:25:10 -0700 Subject: [PATCH 3/9] Copilot suggestion Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/files.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 90981ef7ab7..9be7fcf10bb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -29,7 +29,7 @@ type filesFlags struct { func newFilesCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "files ", + Use: "files", Short: "Manage files in a hosted agent session.", Long: `Manage files in a hosted agent session. From ccdd619fd62206af47c9b3d58443fd1f1c1fa308 Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 16 Mar 2026 11:51:54 -0700 Subject: [PATCH 4/9] Modify files commands to not take name and version Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/files.go | 163 +++++++++++++----- .../internal/cmd/files_test.go | 51 ++---- 2 files changed, 136 insertions(+), 78 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 90981ef7ab7..4e4c74f4856 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -20,11 +20,8 @@ import ( // filesFlags holds the common flags shared by all file subcommands. type filesFlags struct { - accountName string - projectName string - name string - version string - session string + service string // optional: azure.yaml service name for resolution + session string // optional: explicit session ID override } func newFilesCommand() *cobra.Command { @@ -34,7 +31,12 @@ func newFilesCommand() *cobra.Command { Long: `Manage files in a hosted agent session. Upload, download, list, and remove files in the session-scoped filesystem -of a hosted agent. This is useful for debugging, seeding data, and agent setup.`, +of a hosted agent. This is useful for debugging, seeding data, and agent setup. + +Agent details (name, version, endpoint) are automatically resolved from the +azd environment. Use --service to select a specific service when the project +has multiple azure.ai.agent services. The session ID is automatically resolved +from the last invoke session, or can be overridden with --session.`, } cmd.AddCommand(newFilesUploadCommand()) @@ -47,15 +49,62 @@ of a hosted agent. This is useful for debugging, seeding data, and agent setup.` // addFilesFlags registers the common flags on a cobra command. func addFilesFlags(cmd *cobra.Command, flags *filesFlags) { - cmd.Flags().StringVarP(&flags.accountName, "account-name", "a", "", "Cognitive Services account name") - cmd.Flags().StringVarP(&flags.projectName, "project-name", "p", "", "AI Foundry project name") - cmd.Flags().StringVarP(&flags.name, "name", "n", "", "Name of the hosted agent (required)") - cmd.Flags().StringVarP(&flags.version, "version", "v", "", "Version of the hosted agent (required)") - cmd.Flags().StringVarP(&flags.session, "session", "s", "", "Session ID (required)") - - _ = cmd.MarkFlagRequired("name") - _ = cmd.MarkFlagRequired("version") - _ = cmd.MarkFlagRequired("session") + cmd.Flags().StringVar(&flags.service, "service", "", "Azure.yaml service name (auto-detected when only one exists)") + cmd.Flags().StringVarP(&flags.session, "session", "s", "", "Session ID override (defaults to last invoke session)") +} + +// filesContext holds the resolved agent context and session for file operations. +type filesContext struct { + *AgentContext + sessionID string +} + +// resolveFilesContext resolves agent details and session from the azd environment. +func resolveFilesContext(ctx context.Context, flags *filesFlags) (*filesContext, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.service, rootFlags.NoPrompt) + if err != nil { + return nil, err + } + + if info.AgentName == "" { + return nil, fmt.Errorf( + "agent name not found in azd environment for service %q\n\n"+ + "Run 'azd deploy' to deploy the agent, or check that the service is configured in azure.yaml", + info.ServiceName, + ) + } + if info.Version == "" { + return nil, fmt.Errorf( + "agent version not found in azd environment for service %q\n\n"+ + "Run 'azd deploy' to deploy the agent, or check that the service is configured in azure.yaml", + info.ServiceName, + ) + } + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return nil, err + } + + sessionID, err := resolveSessionID(ctx, azdClient, info.AgentName, flags.session, false) + if err != nil { + return nil, err + } + + return &filesContext{ + AgentContext: &AgentContext{ + ProjectEndpoint: endpoint, + Name: info.AgentName, + Version: info.Version, + }, + sessionID: sessionID, + }, nil } // --- upload --- @@ -69,6 +118,7 @@ type filesUploadFlags struct { type FilesUploadAction struct { *AgentContext flags *filesUploadFlags + sessionID string remotePath string } @@ -81,22 +131,28 @@ func newFilesUploadCommand() *cobra.Command { Long: `Upload a file to a hosted agent session. Reads a local file and uploads it to the specified remote path -in the session's filesystem.`, - Example: ` # Upload a file to the session - azd ai agent files upload /data/input.csv --path ./input.csv -n my-agent -v 1 -s `, +in the session's filesystem. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Upload a file to the session (agent auto-detected from azure.yaml) + azd ai agent files upload /data/input.csv --path ./input.csv + + # Upload with explicit service and session + azd ai agent files upload /data/input.csv --path ./input.csv --service my-agent --session `, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) setupDebugLogging(cmd.Flags()) - agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + fc, err := resolveFilesContext(ctx, &flags.filesFlags) if err != nil { return err } action := &FilesUploadAction{ - AgentContext: agentContext, + AgentContext: fc.AgentContext, flags: flags, + sessionID: fc.sessionID, remotePath: args[0], } @@ -129,7 +185,7 @@ func (a *FilesUploadAction) Run(ctx context.Context) error { ctx, a.Name, a.Version, - a.flags.session, + a.sessionID, a.remotePath, DefaultVNextAgentAPIVersion, file, @@ -153,6 +209,7 @@ type filesDownloadFlags struct { type FilesDownloadAction struct { *AgentContext flags *filesDownloadFlags + sessionID string remotePath string } @@ -165,25 +222,31 @@ func newFilesDownloadCommand() *cobra.Command { Long: `Download a file from a hosted agent session. Downloads a file from the specified remote path in the session's -filesystem and saves it locally.`, - Example: ` # Download a file from the session - azd ai agent files download /data/output.csv -o ./output.csv -n my-agent -v 1 -s +filesystem and saves it locally. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Download a file from the session (agent auto-detected) + azd ai agent files download /data/output.csv -o ./output.csv # Download to current directory (uses remote filename) - azd ai agent files download /data/output.csv -n my-agent -v 1 -s `, + azd ai agent files download /data/output.csv + + # Download with explicit session + azd ai agent files download /data/output.csv -o ./output.csv --session `, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) setupDebugLogging(cmd.Flags()) - agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + fc, err := resolveFilesContext(ctx, &flags.filesFlags) if err != nil { return err } action := &FilesDownloadAction{ - AgentContext: agentContext, + AgentContext: fc.AgentContext, flags: flags, + sessionID: fc.sessionID, remotePath: args[0], } @@ -208,7 +271,7 @@ func (a *FilesDownloadAction) Run(ctx context.Context) error { ctx, a.Name, a.Version, - a.flags.session, + a.sessionID, a.remotePath, DefaultVNextAgentAPIVersion, ) @@ -248,6 +311,7 @@ type filesListFlags struct { type FilesListAction struct { *AgentContext flags *filesListFlags + sessionID string remotePath string } @@ -260,21 +324,26 @@ func newFilesListCommand() *cobra.Command { Long: `List files in a hosted agent session. Lists files and directories at the specified path in the session's filesystem. -When no path is provided, lists the root directory.`, - Example: ` # List files in the root directory - azd ai agent files list -n my-agent -v 1 -s +When no path is provided, lists the root directory. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # List files in the root directory (agent auto-detected) + azd ai agent files list # List files in a specific directory - azd ai agent files list /data -n my-agent -v 1 -s + azd ai agent files list /data # List files in table format - azd ai agent files list /data -n my-agent -v 1 -s --output table`, + azd ai agent files list /data --output table + + # List with explicit session + azd ai agent files list --session `, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) setupDebugLogging(cmd.Flags()) - agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + fc, err := resolveFilesContext(ctx, &flags.filesFlags) if err != nil { return err } @@ -285,8 +354,9 @@ When no path is provided, lists the root directory.`, } action := &FilesListAction{ - AgentContext: agentContext, + AgentContext: fc.AgentContext, flags: flags, + sessionID: fc.sessionID, remotePath: remotePath, } @@ -311,7 +381,7 @@ func (a *FilesListAction) Run(ctx context.Context) error { ctx, a.Name, a.Version, - a.flags.session, + a.sessionID, a.remotePath, DefaultVNextAgentAPIVersion, ) @@ -363,6 +433,7 @@ type filesRemoveFlags struct { type FilesRemoveAction struct { *AgentContext flags *filesRemoveFlags + sessionID string remotePath string } @@ -375,25 +446,31 @@ func newFilesRemoveCommand() *cobra.Command { Long: `Remove a file or directory from a hosted agent session. Removes the specified file or directory from the session's filesystem. -Use --recursive to remove directories and their contents.`, - Example: ` # Remove a file - azd ai agent files remove /data/old-file.csv -n my-agent -v 1 -s +Use --recursive to remove directories and their contents. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Remove a file (agent auto-detected) + azd ai agent files remove /data/old-file.csv # Remove a directory recursively - azd ai agent files remove /data/temp --recursive -n my-agent -v 1 -s `, + azd ai agent files remove /data/temp --recursive + + # Remove with explicit session + azd ai agent files remove /data/old-file.csv --session `, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) setupDebugLogging(cmd.Flags()) - agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, flags.version) + fc, err := resolveFilesContext(ctx, &flags.filesFlags) if err != nil { return err } action := &FilesRemoveAction{ - AgentContext: agentContext, + AgentContext: fc.AgentContext, flags: flags, + sessionID: fc.sessionID, remotePath: args[0], } @@ -418,7 +495,7 @@ func (a *FilesRemoveAction) Run(ctx context.Context) error { ctx, a.Name, a.Version, - a.flags.session, + a.sessionID, a.remotePath, a.flags.recursive, DefaultVNextAgentAPIVersion, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go index a5936535210..94ef7c68aa2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go @@ -27,52 +27,42 @@ func TestFilesCommand_HasSubcommands(t *testing.T) { assert.Contains(t, names, "remove") } -func TestFilesUploadCommand_RequiredFlags(t *testing.T) { +func TestFilesUploadCommand_MissingArgs(t *testing.T) { cmd := newFilesUploadCommand() - // No flags and no args should fail + // No args should fail (requires remote-path positional arg) cmd.SetArgs([]string{}) err := cmd.Execute() assert.Error(t, err) } -func TestFilesUploadCommand_MissingName(t *testing.T) { +func TestFilesUploadCommand_MissingPath(t *testing.T) { cmd := newFilesUploadCommand() - cmd.SetArgs([]string{"/remote/path", "--path", "local.txt", "--version", "1", "--session", "abc"}) + // Missing required --path flag + cmd.SetArgs([]string{"/remote/path"}) err := cmd.Execute() assert.Error(t, err) - assert.Contains(t, err.Error(), "name") + assert.Contains(t, err.Error(), "path") } -func TestFilesUploadCommand_MissingVersion(t *testing.T) { +func TestFilesUploadCommand_HasServiceFlag(t *testing.T) { cmd := newFilesUploadCommand() - cmd.SetArgs([]string{"/remote/path", "--path", "local.txt", "--name", "agent", "--session", "abc"}) - err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "version") + f := cmd.Flags().Lookup("service") + require.NotNil(t, f) + assert.Equal(t, "", f.DefValue) } -func TestFilesUploadCommand_MissingSession(t *testing.T) { +func TestFilesUploadCommand_HasSessionFlag(t *testing.T) { cmd := newFilesUploadCommand() - cmd.SetArgs([]string{"/remote/path", "--path", "local.txt", "--name", "agent", "--version", "1"}) - err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "session") + f := cmd.Flags().Lookup("session") + require.NotNil(t, f) + assert.Equal(t, "", f.DefValue) } -func TestFilesUploadCommand_MissingPath(t *testing.T) { - cmd := newFilesUploadCommand() - - cmd.SetArgs([]string{"/remote/path", "--name", "agent", "--version", "1", "--session", "abc"}) - err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "path") -} - -func TestFilesDownloadCommand_RequiredFlags(t *testing.T) { +func TestFilesDownloadCommand_MissingArgs(t *testing.T) { cmd := newFilesDownloadCommand() cmd.SetArgs([]string{}) @@ -87,15 +77,6 @@ func TestFilesDownloadCommand_DefaultOutputPath(t *testing.T) { assert.Equal(t, "", output, "output should default to empty (uses remote filename)") } -func TestFilesListCommand_RequiredFlags(t *testing.T) { - cmd := newFilesListCommand() - - // No flags should fail due to missing required flags - cmd.SetArgs([]string{}) - err := cmd.Execute() - assert.Error(t, err) -} - func TestFilesListCommand_DefaultOutputFormat(t *testing.T) { cmd := newFilesListCommand() @@ -110,7 +91,7 @@ func TestFilesListCommand_OptionalRemotePath(t *testing.T) { assert.NotNil(t, cmd.Args) } -func TestFilesRemoveCommand_RequiredFlags(t *testing.T) { +func TestFilesRemoveCommand_MissingArgs(t *testing.T) { cmd := newFilesRemoveCommand() cmd.SetArgs([]string{}) From 29ad695c0f020782e3d2ca2903f0e498b2317eb3 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 17 Mar 2026 09:58:47 -0700 Subject: [PATCH 5/9] List fixes. Header fixes Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/files.go | 8 ++++++-- .../azure.ai.agents/internal/cmd/files_test.go | 18 ++++++++++++------ .../internal/pkg/agents/agent_api/models.go | 14 ++++++++------ .../pkg/agents/agent_api/operations.go | 8 ++++++++ 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 9653ae5fbf2..11569944319 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -411,12 +411,16 @@ func printFileListTable(fileList *agent_api.SessionFileList) error { fmt.Fprintln(w, "NAME\tPATH\tTYPE\tSIZE\tLAST MODIFIED") fmt.Fprintln(w, "----\t----\t----\t----\t-------------") - for _, f := range fileList.Files { + for _, f := range fileList.Entries { fileType := "file" if f.IsDirectory { fileType = "dir" } - fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", f.Name, f.Path, fileType, f.Size, f.LastModified) + modified := "" + if f.LastModified != nil { + modified = *f.LastModified + } + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", f.Name, f.Path, fileType, f.Size, modified) } return w.Flush() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go index 94ef7c68aa2..9df7f231e58 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go @@ -107,14 +107,16 @@ func TestFilesRemoveCommand_RecursiveDefault(t *testing.T) { } func TestPrintFileListJSON(t *testing.T) { + modified := "2025-01-01T00:00:00Z" fileList := &agent_api.SessionFileList{ - Files: []agent_api.SessionFileInfo{ + Path: "/data", + Entries: []agent_api.SessionFileInfo{ { Name: "test.txt", Path: "/data/test.txt", IsDirectory: false, Size: 1024, - LastModified: "2025-01-01T00:00:00Z", + LastModified: &modified, }, { Name: "subdir", @@ -129,14 +131,16 @@ func TestPrintFileListJSON(t *testing.T) { } func TestPrintFileListTable(t *testing.T) { + modified := "2025-01-01T00:00:00Z" fileList := &agent_api.SessionFileList{ - Files: []agent_api.SessionFileInfo{ + Path: "/data", + Entries: []agent_api.SessionFileInfo{ { Name: "test.txt", Path: "/data/test.txt", IsDirectory: false, Size: 1024, - LastModified: "2025-01-01T00:00:00Z", + LastModified: &modified, }, { Name: "subdir", @@ -152,7 +156,8 @@ func TestPrintFileListTable(t *testing.T) { func TestPrintFileListJSON_Empty(t *testing.T) { fileList := &agent_api.SessionFileList{ - Files: []agent_api.SessionFileInfo{}, + Path: "/", + Entries: []agent_api.SessionFileInfo{}, } err := printFileListJSON(fileList) @@ -161,7 +166,8 @@ func TestPrintFileListJSON_Empty(t *testing.T) { func TestPrintFileListTable_Empty(t *testing.T) { fileList := &agent_api.SessionFileList{ - Files: []agent_api.SessionFileInfo{}, + Path: "/", + Entries: []agent_api.SessionFileInfo{}, } err := printFileListTable(fileList) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index 3f7671e82de..f2d9de784b2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -685,14 +685,16 @@ type StructuredOutputDefinition struct { // SessionFileInfo represents a file or directory entry in a session. type SessionFileInfo struct { - Name string `json:"name"` - Path string `json:"path"` - IsDirectory bool `json:"is_directory"` - Size int64 `json:"size,omitempty"` - LastModified string `json:"last_modified,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + IsDirectory bool `json:"is_dir"` + Size int64 `json:"size,omitempty"` + Mode int `json:"mode,omitempty"` + LastModified *string `json:"modified_time,omitempty"` } // SessionFileList represents the response from listing session files. type SessionFileList struct { - Files []SessionFileInfo `json:"files"` + Path string `json:"path"` + Entries []SessionFileInfo `json:"entries"` } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index 71a4826ac84..cc05be86a6c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -1014,6 +1014,8 @@ func (c *AgentClient) UploadSessionFile( return fmt.Errorf("failed to set request body: %w", err) } + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + resp, err := c.pipeline.Do(req) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) @@ -1056,6 +1058,8 @@ func (c *AgentClient) DownloadSessionFile( runtime.SkipBodyDownload(req) + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + resp, err := c.pipeline.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) @@ -1097,6 +1101,8 @@ func (c *AgentClient) ListSessionFiles( return nil, fmt.Errorf("failed to create request: %w", err) } + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + resp, err := c.pipeline.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) @@ -1150,6 +1156,8 @@ func (c *AgentClient) RemoveSessionFile( return fmt.Errorf("failed to create request: %w", err) } + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + resp, err := c.pipeline.Do(req) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) From ab5b19dd52d6323e62e01ecfce47c69ccb881866 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 17 Mar 2026 13:36:02 -0700 Subject: [PATCH 6/9] Add mkdir command. clean up parameters Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/files.go | 190 +++++++++++++----- .../internal/cmd/files_test.go | 77 ++++--- .../pkg/agents/agent_api/operations.go | 47 +++++ 3 files changed, 233 insertions(+), 81 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 11569944319..f0100915707 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -20,7 +20,7 @@ import ( // filesFlags holds the common flags shared by all file subcommands. type filesFlags struct { - service string // optional: azure.yaml service name for resolution + agentName string // optional: agent name (matches azure.yaml service name) session string // optional: explicit session ID override } @@ -34,7 +34,7 @@ Upload, download, list, and remove files in the session-scoped filesystem of a hosted agent. This is useful for debugging, seeding data, and agent setup. Agent details (name, version, endpoint) are automatically resolved from the -azd environment. Use --service to select a specific service when the project +azd environment. Use --agent-name to select a specific agent when the project has multiple azure.ai.agent services. The session ID is automatically resolved from the last invoke session, or can be overridden with --session.`, } @@ -43,13 +43,14 @@ from the last invoke session, or can be overridden with --session.`, cmd.AddCommand(newFilesDownloadCommand()) cmd.AddCommand(newFilesListCommand()) cmd.AddCommand(newFilesRemoveCommand()) + cmd.AddCommand(newFilesMkdirCommand()) return cmd } // addFilesFlags registers the common flags on a cobra command. func addFilesFlags(cmd *cobra.Command, flags *filesFlags) { - cmd.Flags().StringVar(&flags.service, "service", "", "Azure.yaml service name (auto-detected when only one exists)") + cmd.Flags().StringVarP(&flags.agentName, "agent-name", "n", "", "Agent name (matches azure.yaml service name; auto-detected when only one exists)") cmd.Flags().StringVarP(&flags.session, "session", "s", "", "Session ID override (defaults to last invoke session)") } @@ -67,7 +68,7 @@ func resolveFilesContext(ctx context.Context, flags *filesFlags) (*filesContext, } defer azdClient.Close() - info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.service, rootFlags.NoPrompt) + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.agentName, rootFlags.NoPrompt) if err != nil { return nil, err } @@ -111,35 +112,38 @@ func resolveFilesContext(ctx context.Context, flags *filesFlags) (*filesContext, type filesUploadFlags struct { filesFlags - localPath string + file string + targetPath string } // FilesUploadAction handles uploading a file to a session. type FilesUploadAction struct { *AgentContext - flags *filesUploadFlags - sessionID string - remotePath string + flags *filesUploadFlags + sessionID string } func newFilesUploadCommand() *cobra.Command { flags := &filesUploadFlags{} cmd := &cobra.Command{ - Use: "upload ", + Use: "upload", Short: "Upload a file to a hosted agent session.", Long: `Upload a file to a hosted agent session. Reads a local file and uploads it to the specified remote path -in the session's filesystem. +in the session's filesystem. If --target-path is not provided, +the remote path defaults to the local file path. Agent details are automatically resolved from the azd environment.`, - Example: ` # Upload a file to the session (agent auto-detected from azure.yaml) - azd ai agent files upload /data/input.csv --path ./input.csv + Example: ` # Upload a file (remote path defaults to local path) + azd ai agent files upload --file ./data/input.csv + + # Upload to a specific remote path + azd ai agent files upload --file ./input.csv --target-path /data/input.csv - # Upload with explicit service and session - azd ai agent files upload /data/input.csv --path ./input.csv --service my-agent --session `, - Args: cobra.ExactArgs(1), + # Upload with explicit agent name and session + azd ai agent files upload --file ./input.csv --agent-name my-agent --session `, RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) setupDebugLogging(cmd.Flags()) @@ -153,7 +157,6 @@ Agent details are automatically resolved from the azd environment.`, AgentContext: fc.AgentContext, flags: flags, sessionID: fc.sessionID, - remotePath: args[0], } return action.Run(ctx) @@ -161,18 +164,24 @@ Agent details are automatically resolved from the azd environment.`, } addFilesFlags(cmd, &flags.filesFlags) - cmd.Flags().StringVar(&flags.localPath, "path", "", "Local file path to upload (required)") - _ = cmd.MarkFlagRequired("path") + cmd.Flags().StringVarP(&flags.file, "file", "f", "", "Local file path to upload (required)") + cmd.Flags().StringVarP(&flags.targetPath, "target-path", "t", "", "Remote destination path (defaults to local file path)") + _ = cmd.MarkFlagRequired("file") return cmd } // Run executes the upload action. func (a *FilesUploadAction) Run(ctx context.Context) error { - //nolint:gosec // G304: localPath is provided by the user via CLI flag - file, err := os.Open(a.flags.localPath) + remotePath := a.flags.targetPath + if remotePath == "" { + remotePath = a.flags.file + } + + //nolint:gosec // G304: file path is provided by the user via CLI flag + file, err := os.Open(a.flags.file) if err != nil { - return fmt.Errorf("failed to open local file %q: %w", a.flags.localPath, err) + return fmt.Errorf("failed to open local file %q: %w", a.flags.file, err) } defer file.Close() @@ -186,7 +195,7 @@ func (a *FilesUploadAction) Run(ctx context.Context) error { a.Name, a.Version, a.sessionID, - a.remotePath, + remotePath, DefaultVNextAgentAPIVersion, file, ) @@ -194,7 +203,7 @@ func (a *FilesUploadAction) Run(ctx context.Context) error { return fmt.Errorf("failed to upload file: %w", err) } - fmt.Printf("Uploaded %s → %s\n", a.flags.localPath, a.remotePath) + fmt.Printf("Uploaded %s → %s\n", a.flags.file, remotePath) return nil } @@ -202,38 +211,38 @@ func (a *FilesUploadAction) Run(ctx context.Context) error { type filesDownloadFlags struct { filesFlags - outputPath string + file string + targetPath string } // FilesDownloadAction handles downloading a file from a session. type FilesDownloadAction struct { *AgentContext - flags *filesDownloadFlags - sessionID string - remotePath string + flags *filesDownloadFlags + sessionID string } func newFilesDownloadCommand() *cobra.Command { flags := &filesDownloadFlags{} cmd := &cobra.Command{ - Use: "download ", + Use: "download", Short: "Download a file from a hosted agent session.", Long: `Download a file from a hosted agent session. Downloads a file from the specified remote path in the session's -filesystem and saves it locally. +filesystem and saves it locally. If --target-path is not provided, +the local path defaults to the basename of the remote file. Agent details are automatically resolved from the azd environment.`, - Example: ` # Download a file from the session (agent auto-detected) - azd ai agent files download /data/output.csv -o ./output.csv + Example: ` # Download a file (local path defaults to remote filename) + azd ai agent files download --file /data/output.csv - # Download to current directory (uses remote filename) - azd ai agent files download /data/output.csv + # Download to a specific local path + azd ai agent files download --file /data/output.csv --target-path ./output.csv # Download with explicit session - azd ai agent files download /data/output.csv -o ./output.csv --session `, - Args: cobra.ExactArgs(1), + azd ai agent files download --file /data/output.csv --session `, RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) setupDebugLogging(cmd.Flags()) @@ -247,7 +256,6 @@ Agent details are automatically resolved from the azd environment.`, AgentContext: fc.AgentContext, flags: flags, sessionID: fc.sessionID, - remotePath: args[0], } return action.Run(ctx) @@ -255,7 +263,9 @@ Agent details are automatically resolved from the azd environment.`, } addFilesFlags(cmd, &flags.filesFlags) - cmd.Flags().StringVarP(&flags.outputPath, "output", "o", "", "Local output file path (defaults to remote filename)") + cmd.Flags().StringVarP(&flags.file, "file", "f", "", "Remote file path to download (required)") + cmd.Flags().StringVarP(&flags.targetPath, "target-path", "t", "", "Local destination path (defaults to remote filename)") + _ = cmd.MarkFlagRequired("file") return cmd } @@ -272,7 +282,7 @@ func (a *FilesDownloadAction) Run(ctx context.Context) error { a.Name, a.Version, a.sessionID, - a.remotePath, + a.flags.file, DefaultVNextAgentAPIVersion, ) if err != nil { @@ -280,15 +290,15 @@ func (a *FilesDownloadAction) Run(ctx context.Context) error { } defer body.Close() - outputPath := a.flags.outputPath - if outputPath == "" { - outputPath = filepath.Base(a.remotePath) + targetPath := a.flags.targetPath + if targetPath == "" { + targetPath = filepath.Base(a.flags.file) } - //nolint:gosec // G304: outputPath is provided by the user via CLI flag - outFile, err := os.Create(outputPath) + //nolint:gosec // G304: targetPath is provided by the user via CLI flag + outFile, err := os.Create(targetPath) if err != nil { - return fmt.Errorf("failed to create output file %q: %w", outputPath, err) + return fmt.Errorf("failed to create output file %q: %w", targetPath, err) } defer outFile.Close() @@ -296,7 +306,7 @@ func (a *FilesDownloadAction) Run(ctx context.Context) error { return fmt.Errorf("failed to write file: %w", err) } - fmt.Printf("Downloaded %s → %s\n", a.remotePath, outputPath) + fmt.Printf("Downloaded %s → %s\n", a.flags.file, targetPath) return nil } @@ -443,9 +453,10 @@ type FilesRemoveAction struct { func newFilesRemoveCommand() *cobra.Command { flags := &filesRemoveFlags{} + var filePath string cmd := &cobra.Command{ - Use: "remove ", + Use: "remove", Short: "Remove a file or directory from a hosted agent session.", Long: `Remove a file or directory from a hosted agent session. @@ -454,14 +465,13 @@ Use --recursive to remove directories and their contents. Agent details are automatically resolved from the azd environment.`, Example: ` # Remove a file (agent auto-detected) - azd ai agent files remove /data/old-file.csv + azd ai agent files remove --file /data/old-file.csv # Remove a directory recursively - azd ai agent files remove /data/temp --recursive + azd ai agent files remove --file /data/temp --recursive # Remove with explicit session - azd ai agent files remove /data/old-file.csv --session `, - Args: cobra.ExactArgs(1), + azd ai agent files remove --file /data/old-file.csv --session `, RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) setupDebugLogging(cmd.Flags()) @@ -475,7 +485,7 @@ Agent details are automatically resolved from the azd environment.`, AgentContext: fc.AgentContext, flags: flags, sessionID: fc.sessionID, - remotePath: args[0], + remotePath: filePath, } return action.Run(ctx) @@ -483,6 +493,8 @@ Agent details are automatically resolved from the azd environment.`, } addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Remote file or directory path to remove") + _ = cmd.MarkFlagRequired("file") cmd.Flags().BoolVar(&flags.recursive, "recursive", false, "Recursively remove directories and their contents") return cmd @@ -511,3 +523,79 @@ func (a *FilesRemoveAction) Run(ctx context.Context) error { fmt.Printf("Removed %s\n", a.remotePath) return nil } + +// --- mkdir --- + +// FilesMkdirAction handles creating a directory in a session. +type FilesMkdirAction struct { + *AgentContext + sessionID string + remotePath string +} + +func newFilesMkdirCommand() *cobra.Command { + flags := &filesFlags{} + var dirPath string + + cmd := &cobra.Command{ + Use: "mkdir", + Short: "Create a directory in a hosted agent session.", + Long: `Create a directory in a hosted agent session. + +Creates the specified directory in the session's filesystem. +Parent directories are created as needed. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Create a directory (agent auto-detected) + azd ai agent files mkdir --dir /data/output + + # Create with explicit session + azd ai agent files mkdir --dir /data/output --session `, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, flags) + if err != nil { + return err + } + + action := &FilesMkdirAction{ + AgentContext: fc.AgentContext, + sessionID: fc.sessionID, + remotePath: dirPath, + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, flags) + cmd.Flags().StringVarP(&dirPath, "dir", "d", "", "Remote directory path to create") + _ = cmd.MarkFlagRequired("dir") + + return cmd +} + +// Run executes the mkdir action. +func (a *FilesMkdirAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + err = agentClient.MkdirSessionFile( + ctx, + a.Name, + a.Version, + a.sessionID, + a.remotePath, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + fmt.Printf("Created %s\n", a.remotePath) + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go index 9df7f231e58..9abdc9d3729 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go @@ -27,54 +27,44 @@ func TestFilesCommand_HasSubcommands(t *testing.T) { assert.Contains(t, names, "remove") } -func TestFilesUploadCommand_MissingArgs(t *testing.T) { +func TestFilesUploadCommand_MissingFile(t *testing.T) { cmd := newFilesUploadCommand() - // No args should fail (requires remote-path positional arg) + // Missing required --file flag cmd.SetArgs([]string{}) err := cmd.Execute() assert.Error(t, err) + assert.Contains(t, err.Error(), "file") } -func TestFilesUploadCommand_MissingPath(t *testing.T) { +func TestFilesUploadCommand_HasFlags(t *testing.T) { cmd := newFilesUploadCommand() - // Missing required --path flag - cmd.SetArgs([]string{"/remote/path"}) - err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "path") -} - -func TestFilesUploadCommand_HasServiceFlag(t *testing.T) { - cmd := newFilesUploadCommand() - - f := cmd.Flags().Lookup("service") - require.NotNil(t, f) - assert.Equal(t, "", f.DefValue) -} - -func TestFilesUploadCommand_HasSessionFlag(t *testing.T) { - cmd := newFilesUploadCommand() - - f := cmd.Flags().Lookup("session") - require.NotNil(t, f) - assert.Equal(t, "", f.DefValue) + for _, name := range []string{"file", "target-path", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + assert.Equal(t, "", f.DefValue) + } } -func TestFilesDownloadCommand_MissingArgs(t *testing.T) { +func TestFilesDownloadCommand_MissingFile(t *testing.T) { cmd := newFilesDownloadCommand() + // Missing required --file flag cmd.SetArgs([]string{}) err := cmd.Execute() assert.Error(t, err) + assert.Contains(t, err.Error(), "file") } -func TestFilesDownloadCommand_DefaultOutputPath(t *testing.T) { +func TestFilesDownloadCommand_HasFlags(t *testing.T) { cmd := newFilesDownloadCommand() - output, _ := cmd.Flags().GetString("output") - assert.Equal(t, "", output, "output should default to empty (uses remote filename)") + for _, name := range []string{"file", "target-path", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + assert.Equal(t, "", f.DefValue) + } } func TestFilesListCommand_DefaultOutputFormat(t *testing.T) { @@ -91,21 +81,48 @@ func TestFilesListCommand_OptionalRemotePath(t *testing.T) { assert.NotNil(t, cmd.Args) } -func TestFilesRemoveCommand_MissingArgs(t *testing.T) { +func TestFilesRemoveCommand_MissingFile(t *testing.T) { cmd := newFilesRemoveCommand() + // Missing required --file flag cmd.SetArgs([]string{}) err := cmd.Execute() assert.Error(t, err) + assert.Contains(t, err.Error(), "file") } -func TestFilesRemoveCommand_RecursiveDefault(t *testing.T) { +func TestFilesRemoveCommand_HasFlags(t *testing.T) { cmd := newFilesRemoveCommand() + for _, name := range []string{"file", "recursive", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + } + recursive, _ := cmd.Flags().GetBool("recursive") assert.False(t, recursive, "recursive should default to false") } +func TestFilesMkdirCommand_MissingDir(t *testing.T) { + cmd := newFilesMkdirCommand() + + // Missing required --dir flag + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "dir") +} + +func TestFilesMkdirCommand_HasFlags(t *testing.T) { + cmd := newFilesMkdirCommand() + + for _, name := range []string{"dir", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + assert.Equal(t, "", f.DefValue) + } +} + func TestPrintFileListJSON(t *testing.T) { modified := "2025-01-01T00:00:00Z" fileList := &agent_api.SessionFileList{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index cc05be86a6c..ec313bbb23f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -1170,3 +1170,50 @@ func (c *AgentClient) RemoveSessionFile( return nil } + +// MkdirSessionFile creates a directory in a session's filesystem. +func (c *AgentClient) MkdirSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath string, + apiVersion string, +) error { + u, err := url.Parse(c.endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files/mkdir", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + u.RawQuery = query.Encode() + + body, err := json.Marshal(map[string]string{"path": remotePath}) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, u.String()) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Raw().Header.Set("Content-Type", "application/json") + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + req.SetBody(streaming.NopCloser(bytes.NewReader(body)), "application/json") + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusNoContent) { + return runtime.NewResponseError(resp) + } + + return nil +} From ce6001f1291cd5800cb97b17f64cf908140439b3 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 17 Mar 2026 15:06:02 -0700 Subject: [PATCH 7/9] Linter Signed-off-by: trangevi --- cli/azd/extensions/azure.ai.agents/internal/cmd/files.go | 2 +- .../internal/pkg/agents/agent_api/operations.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index f0100915707..65c1da2b99f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -21,7 +21,7 @@ import ( // filesFlags holds the common flags shared by all file subcommands. type filesFlags struct { agentName string // optional: agent name (matches azure.yaml service name) - session string // optional: explicit session ID override + session string // optional: explicit session ID override } func newFilesCommand() *cobra.Command { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index ec313bbb23f..e10efa379ba 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -1203,7 +1203,10 @@ func (c *AgentClient) MkdirSessionFile( req.Raw().Header.Set("Content-Type", "application/json") req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") - req.SetBody(streaming.NopCloser(bytes.NewReader(body)), "application/json") + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(body)), "application/json"); err != nil { + return fmt.Errorf("failed to set request body: %w", err) + } resp, err := c.pipeline.Do(req) if err != nil { From 447f76c0b0f362a724fda4b01859c142599e7c60 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 18 Mar 2026 11:17:16 -0700 Subject: [PATCH 8/9] Add stat command Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/files.go | 112 ++++++++++++++++++ .../pkg/agents/agent_api/operations.go | 50 ++++++++ 2 files changed, 162 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 65c1da2b99f..0122e214c6c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -44,6 +44,7 @@ from the last invoke session, or can be overridden with --session.`, cmd.AddCommand(newFilesListCommand()) cmd.AddCommand(newFilesRemoveCommand()) cmd.AddCommand(newFilesMkdirCommand()) + cmd.AddCommand(newFilesStatCommand()) return cmd } @@ -599,3 +600,114 @@ func (a *FilesMkdirAction) Run(ctx context.Context) error { fmt.Printf("Created %s\n", a.remotePath) return nil } + +// --- stat --- + +type filesStatFlags struct { + filesFlags + output string +} + +// FilesStatAction handles getting file/directory metadata from a session. +type FilesStatAction struct { + *AgentContext + flags *filesStatFlags + sessionID string + remotePath string +} + +func newFilesStatCommand() *cobra.Command { + flags := &filesStatFlags{} + + cmd := &cobra.Command{ + Use: "stat ", + Short: "Get file or directory metadata in a hosted agent session.", + Long: `Get file or directory metadata in a hosted agent session. + +Returns metadata about the specified file or directory in the session's filesystem. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Get metadata for a file + azd ai agent files stat /data/output.csv + + # Get metadata in table format + azd ai agent files stat /data/output.csv --output table + + # Get metadata with explicit session + azd ai agent files stat /data/output.csv --session `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, &flags.filesFlags) + if err != nil { + return err + } + + action := &FilesStatAction{ + AgentContext: fc.AgentContext, + flags: flags, + sessionID: fc.sessionID, + remotePath: args[0], + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVarP(&flags.output, "output", "o", "json", "Output format (json or table)") + + return cmd +} + +// Run executes the stat action. +func (a *FilesStatAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + fileInfo, err := agentClient.StatSessionFile( + ctx, + a.Name, + a.Version, + a.sessionID, + a.remotePath, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + + if a.flags.output == "table" { + return printFileInfoTable(fileInfo) + } + + output, err := json.MarshalIndent(fileInfo, "", " ") + if err != nil { + return fmt.Errorf("failed to format output: %w", err) + } + + fmt.Println(string(output)) + return nil +} + +func printFileInfoTable(f *agent_api.SessionFileInfo) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tPATH\tTYPE\tSIZE\tLAST MODIFIED") + fmt.Fprintln(w, "----\t----\t----\t----\t-------------") + + fileType := "file" + if f.IsDirectory { + fileType = "dir" + } + modified := "" + if f.LastModified != nil { + modified = *f.LastModified + } + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", f.Name, f.Path, fileType, f.Size, modified) + + return w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index e10efa379ba..24b6a14a08c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -1220,3 +1220,53 @@ func (c *AgentClient) MkdirSessionFile( return nil } + +// StatSessionFile returns file/directory metadata from a session's filesystem. +func (c *AgentClient) StatSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, +) (*SessionFileInfo, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files/stat", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodGet, u.String()) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var fileInfo SessionFileInfo + if err := json.Unmarshal(respBody, &fileInfo); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &fileInfo, nil +} From 3545449e4a4eb16395fdaacdde233ec30a0255d7 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 18 Mar 2026 11:46:03 -0700 Subject: [PATCH 9/9] PR comment Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/files.go | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 0122e214c6c..1e62615100a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "strconv" "text/tabwriter" "azureaiagent/internal/pkg/agents/agent_api" @@ -24,10 +25,41 @@ type filesFlags struct { session string // optional: explicit session ID override } +// isVNextEnabled checks whether hosted agent vnext is enabled +// by looking at both the OS environment and the azd environment. +func isVNextEnabled(ctx context.Context) bool { + if v := os.Getenv("enableHostedAgentVNext"); v != "" { + if enabled, err := strconv.ParseBool(v); err == nil && enabled { + return true + } + } + + // Best-effort check of azd environment + azdClient, err := azdext.NewAzdClient() + if err != nil { + return false + } + defer azdClient.Close() + + azdEnv, err := loadAzdEnvironment(ctx, azdClient) + if err != nil { + return false + } + + if v := azdEnv["enableHostedAgentVNext"]; v != "" { + if enabled, err := strconv.ParseBool(v); err == nil && enabled { + return true + } + } + + return false +} + func newFilesCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "files", - Short: "Manage files in a hosted agent session.", + Use: "files", + Short: "Manage files in a hosted agent session.", + Hidden: !isVNextEnabled(context.Background()), Long: `Manage files in a hosted agent session. Upload, download, list, and remove files in the session-scoped filesystem @@ -37,6 +69,23 @@ Agent details (name, version, endpoint) are automatically resolved from the azd environment. Use --agent-name to select a specific agent when the project has multiple azure.ai.agent services. The session ID is automatically resolved from the last invoke session, or can be overridden with --session.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Chain with parent's PersistentPreRunE (root sets NoPrompt) + if parent := cmd.Parent(); parent != nil && parent.PersistentPreRunE != nil { + if err := parent.PersistentPreRunE(cmd, args); err != nil { + return err + } + } + + ctx := azdext.WithAccessToken(cmd.Context()) + if !isVNextEnabled(ctx) { + return fmt.Errorf( + "files commands require hosted agent vnext to be enabled\n\n" + + "Set 'enableHostedAgentVNext' to 'true' in your azd environment or as an OS environment variable.", + ) + } + return nil + }, } cmd.AddCommand(newFilesUploadCommand())