diff --git a/manifest/addendum.json b/manifest/addendum.json deleted file mode 100644 index c626f4a8..00000000 --- a/manifest/addendum.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "addendumVersion": "1.0", - "copilotSettings": { - "elementType": "Extensions", - "isDigitalWorker": true - } -} \ No newline at end of file diff --git a/manifest/manifest.json b/manifest/manifest.json deleted file mode 100644 index 0b222f0c..00000000 --- a/manifest/manifest.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "version": "1.0.0", // Update this version when you make changes to the manifest - "id": "a99017af-5a99-4780-b000-1b7c7c825d55", - "developer": { - "name": "specify name of developer", - "websiteUrl": "https://go.microsoft.com/fwlink/?linkid=2138949", - "privacyUrl": "https://go.microsoft.com/fwlink/?linkid=2138950", - "termsOfUseUrl": "https://go.microsoft.com/fwlink/?linkid=2138865", - "mpnId": "0000000" - }, - "name": { - "short": "", - "full": "" - }, - "description": { - "short": "Use this field to add a short description about the agent", - "full": "Use this field to add a full description about the agent" - }, - "icons": { - "outline": "outline.png", - "color": "color.png" - }, - "accentColor": "#07687d", - "bots": [ - { - "botId": "f082901b-bcf9-44ae-acb1-6b55fc6e9d56", - "isNotificationOnly": false, - "supportsFiles": true, - "scopes": [ - "personal", - "team", - "groupChat", - "copilot" - ] - } - ], - "validDomains": [ - "*.botframework.com", - "*.*.botframework.com" - ], - "webApplicationInfo": { - "id": "f082901b-bcf9-44ae-acb1-6b55fc6e9d56", - "resource": "api://f082901b-bcf9-44ae-acb1-6b55fc6e9d56" - }, - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/vdevPreview/MicrosoftTeams.schema.json", - "manifestVersion": "devPreview", - "copilotAgents": { - "customEngineAgents": [ - { - "id": "f082901b-bcf9-44ae-acb1-6b55fc6e9d56", - "type": "bot" - } - ] - } -} \ No newline at end of file diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index 4dea74fd..20c16e8a 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -183,6 +183,112 @@ a365 develop-mcp list-servers -e "myenv" --verbose - Defaults to "prod" when no config file is specified - Follows KISS principles to avoid over-engineering common scenarios +### Publish Command + +The `publish` command packages and publishes your agent manifest to the MOS (Microsoft Online Services) Titles service. It uses **embedded templates** for complete portability - no external file dependencies required. + +**Key Features:** +- **Embedded Templates**: Manifest templates (JSON + PNG) are embedded in the CLI binary +- **Fully Portable**: No external file dependencies - works from any directory +- **Automatic ID Updates**: Updates both `manifest.json` and `agenticUserTemplateManifest.json` with agent blueprint ID +- **Interactive Customization**: Prompts for manifest customization before upload +- **Graceful Degradation**: Falls back to manual upload if permissions are insufficient +- **Graph API Integration**: Configures federated identity credentials and role assignments + +**Command Options:** +- `a365 publish` — Publish agent manifest with embedded templates +- `a365 publish --dry-run` — Preview changes without uploading +- `a365 publish --skip-graph` — Skip Graph API operations (federated identity, role assignments) +- `a365 publish --mos-env ` — Target specific MOS environment (default: prod) +- `a365 publish --mos-token ` — Override MOS authentication token + +**Manifest Structure:** + +The publish command works with two manifest files: + +1. **`manifest.json`** - Teams app manifest with agent metadata + - Updated fields: `id`, `name.short`, `name.full`, `bots[0].botId` + +2. **`agenticUserTemplateManifest.json`** - Agent identity blueprint configuration + - Updated fields: `agentIdentityBlueprintId` (replaces old `webApplicationInfo.id`) + +**Workflow:** + +```bash +# 1. Ensure you have a valid configuration +a365 config display + +# 2. Run setup to create agent blueprint (if not already done) +a365 setup + +# 3. Publish the manifest +a365 publish +``` + +**Interactive Customization Prompt:** + +Before uploading, you'll be prompted to customize: +- **Version**: Must increment for republishing (e.g., 1.0.0 → 1.0.1) +- **Agent Name**: Short (≤30 chars) and full display names +- **Descriptions**: Short (1-2 sentences) and full capabilities +- **Developer Info**: Name, website URL, privacy URL +- **Icons**: Custom branding (color.png, outline.png) + +**Manual Upload Fallback:** + +If you receive an authorization error (401/403), the CLI will: +1. Create the manifest package locally in a temporary directory +2. Display the package location +3. Provide instructions for manual upload to MOS Titles portal +4. Reference documentation for detailed steps + +**Example:** + +```bash +# Standard publish +a365 publish + +# Dry run to preview changes +a365 publish --dry-run + +# Skip Graph API operations +a365 publish --skip-graph + +# Use custom MOS environment +$env:MOS_TITLES_URL = "https://titles.dev.mos.microsoft.com" +a365 publish +``` + +**Manual Upload Instructions:** + +If automated upload fails due to insufficient privileges: + +1. Locate the generated `manifest.zip` file (path shown in error message) +2. Navigate to MOS Titles portal: `https://titles.prod.mos.microsoft.com` +3. Go to Packages section +4. Upload the manifest.zip file +5. Follow the portal workflow to complete publishing + +For detailed MOS upload instructions, see the [MOS Titles Documentation](https://aka.ms/mos-titles-docs). + +**Architecture Details:** + +- **ManifestTemplateService**: Handles embedded resource extraction and manifest customization +- **Embedded Resources**: 4 files embedded at build time: + - `manifest.json` - Base Teams app manifest + - `agenticUserTemplateManifest.json` - Agent identity blueprint manifest + - `color.png` - Color icon (192x192) + - `outline.png` - Outline icon (32x32) +- **Temporary Working Directory**: Templates extracted to temp directory, customized, then zipped +- **Automatic Cleanup**: Temp directory removed after successful publish + +**Error Handling:** + +- **401 Unauthorized / 403 Forbidden**: Graceful fallback with manual upload instructions +- **Missing Blueprint ID**: Clear error message directing user to run `a365 setup` +- **Invalid Manifest**: JSON validation errors with specific field information +- **Network Errors**: Detailed HTTP status codes and response bodies for troubleshooting + ## Inheritable Permissions: Best Practice Agent 365 CLI and the Agent 365 platform are designed to use inheritable permissions on agent blueprints. This means: diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index defa6d63..4d16b3f1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -83,7 +83,8 @@ private static string GetProjectDirectory(Agent365Config config, ILogger logger) public static Command CreateCommand( ILogger logger, IConfigService configService, - GraphApiService graphApiService) + GraphApiService graphApiService, + ManifestTemplateService manifestTemplateService) { var command = new Command("publish", "Update manifest.json IDs and publish package; configure federated identity and app role assignments"); @@ -116,8 +117,24 @@ public static Command CreateCommand( // Use deploymentProjectPath from config for portability var baseDir = GetProjectDirectory(config, logger); - var manifestPath = Path.Combine(baseDir, "manifest", "manifest.json"); - var manifestDir = Path.GetDirectoryName(manifestPath)!; + var manifestDir = Path.Combine(baseDir, "manifest"); + var manifestPath = Path.Combine(manifestDir, "manifest.json"); + + // If manifest directory doesn't exist, extract templates from embedded resources + if (!Directory.Exists(manifestDir)) + { + logger.LogInformation("Manifest directory not found. Extracting templates from embedded resources..."); + Directory.CreateDirectory(manifestDir); + + if (!manifestTemplateService.ExtractTemplates(manifestDir)) + { + logger.LogError("Failed to extract manifest templates from embedded resources"); + return; + } + + logger.LogInformation("Successfully extracted manifest templates to {ManifestDir}", manifestDir); + logger.LogInformation("Please customize the manifest files before publishing"); + } if (!File.Exists(manifestPath)) { @@ -294,7 +311,29 @@ public static Command CreateCommand( var fileContent = new StreamContent(zipFs); fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); form.Add(fileContent, "package", Path.GetFileName(zipPath)); - var uploadResp = await http.PostAsync(packagesUrl, form); + + HttpResponseMessage uploadResp; + try + { + uploadResp = await http.PostAsync(packagesUrl, form); + } + catch (HttpRequestException ex) + { + logger.LogError("Network error during package upload: {Message}", ex.Message); + logger.LogInformation("The manifest package is available at: {ZipPath}", zipPath); + logger.LogInformation("You can manually upload it at: {Url}", packagesUrl); + logger.LogInformation("When network connectivity is restored, you can retry the publish command."); + return; + } + catch (TaskCanceledException ex) + { + logger.LogError("Upload request timed out: {Message}", ex.Message); + logger.LogInformation("The manifest package is available at: {ZipPath}", zipPath); + logger.LogInformation("You can manually upload it at: {Url}", packagesUrl); + logger.LogInformation("When network connectivity is restored, you can retry the publish command."); + return; + } + var uploadBody = await uploadResp.Content.ReadAsStringAsync(); logger.LogInformation("Titles upload HTTP {StatusCode}. Raw body length={Length} bytes", (int)uploadResp.StatusCode, uploadBody?.Length ?? 0); if (!uploadResp.IsSuccessStatusCode) @@ -352,7 +391,28 @@ public static Command CreateCommand( // POST titles with operationId - using tenant-specific URL var titlesUrl = $"{mosTitlesBaseUrl}/admin/v1/tenants/packages/titles"; var titlePayload = JsonSerializer.Serialize(new { operationId }); - var titlesResp = await http.PostAsync(titlesUrl, new StringContent(titlePayload, System.Text.Encoding.UTF8, "application/json")); + + HttpResponseMessage titlesResp; + try + { + using (var content = new StringContent(titlePayload, System.Text.Encoding.UTF8, "application/json")) + { + titlesResp = await http.PostAsync(titlesUrl, content); + } + } + catch (HttpRequestException ex) + { + logger.LogError("Network error during title creation: {Message}", ex.Message); + logger.LogInformation("Package was uploaded successfully (operationId={Op}), but title creation failed.", operationId); + return; + } + catch (TaskCanceledException ex) + { + logger.LogError("Title creation request timed out: {Message}", ex.Message); + logger.LogInformation("Package was uploaded successfully (operationId={Op}), but title creation failed.", operationId); + return; + } + var titlesBody = await titlesResp.Content.ReadAsStringAsync(); if (!titlesResp.IsSuccessStatusCode) { @@ -375,7 +435,26 @@ public static Command CreateCommand( Entities = Array.Empty() } }); - var allowResp = await http.PostAsync(allowUrl, new StringContent(allowedPayload, System.Text.Encoding.UTF8, "application/json")); + + HttpResponseMessage allowResp; + try + { + using var content = new StringContent(allowedPayload, System.Text.Encoding.UTF8, "application/json"); + allowResp = await http.PostAsync(allowUrl, content); + } + catch (HttpRequestException ex) + { + logger.LogError("Network error during access configuration: {Message}", ex.Message); + logger.LogInformation("Title was created (titleId={Title}), but access configuration failed.", titleId); + return; + } + catch (TaskCanceledException ex) + { + logger.LogError("Access configuration request timed out: {Message}", ex.Message); + logger.LogInformation("Title was created (titleId={Title}), but access configuration failed.", titleId); + return; + } + var allowBody = await allowResp.Content.ReadAsStringAsync(); if (!allowResp.IsSuccessStatusCode) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj b/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj index 87327449..a421cd0e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj @@ -57,4 +57,12 @@ + + + + + + + + diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 3f593e5e..074a1f4e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -91,10 +91,11 @@ static async Task Main(string[] args) var configLoggerFactory = serviceProvider.GetRequiredService(); var configLogger = configLoggerFactory.CreateLogger("ConfigCommand"); var wizardService = serviceProvider.GetRequiredService(); + var manifestTemplateService = serviceProvider.GetRequiredService(); rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService)); rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, executor)); - rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, graphApiService)); + rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, graphApiService, manifestTemplateService)); // Invoke return await rootCommand.InvokeAsync(args); @@ -217,6 +218,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // For AgentApplication.Create permission + services.AddSingleton(); // For publish command template extraction // Register AzureWebAppCreator for SDK-based web app creation services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ManifestTemplateService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ManifestTemplateService.cs new file mode 100644 index 00000000..c09217c7 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ManifestTemplateService.cs @@ -0,0 +1,402 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO.Compression; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for managing manifest templates embedded in the CLI binary. +/// Handles extraction, customization, and packaging of manifest files. +/// +public class ManifestTemplateService +{ + private readonly ILogger _logger; + private const string ResourcePrefix = "Microsoft.Agents.A365.DevTools.Cli.Templates."; + + public ManifestTemplateService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Extracts embedded manifest templates to a working directory. + /// + /// Directory to extract templates to + /// True if extraction succeeded + public bool ExtractTemplates(string workingDirectory) + { + try + { + if (!Directory.Exists(workingDirectory)) + { + Directory.CreateDirectory(workingDirectory); + } + + var assembly = Assembly.GetExecutingAssembly(); + var resourceNames = new[] + { + "manifest.json", + "agenticUserTemplateManifest.json", + "color.png", + "outline.png" + }; + + foreach (var resourceName in resourceNames) + { + var fullResourceName = $"{ResourcePrefix}{resourceName}"; + using var stream = assembly.GetManifestResourceStream(fullResourceName); + + if (stream == null) + { + _logger.LogError("Embedded resource not found: {Resource}", fullResourceName); + return false; + } + + var targetPath = Path.Combine(workingDirectory, resourceName); + using var fileStream = File.Create(targetPath); + stream.CopyTo(fileStream); + + _logger.LogDebug("Extracted template: {File}", resourceName); + } + + _logger.LogInformation("Extracted manifest templates to {Directory}", workingDirectory); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to extract manifest templates"); + return false; + } + } + + /// + /// Updates manifest files with agent-specific identifiers. + /// + /// Directory containing extracted templates + /// Agent blueprint ID to inject + /// Display name for the agent (optional) + /// True if update succeeded + public async Task UpdateManifestIdentifiersAsync( + string workingDirectory, + string blueprintId, + string? agentDisplayName = null) + { + try + { + // Update manifest.json + var manifestPath = Path.Combine(workingDirectory, "manifest.json"); + if (!File.Exists(manifestPath)) + { + _logger.LogError("Manifest file not found at {Path}", manifestPath); + return false; + } + + var manifestText = await File.ReadAllTextAsync(manifestPath); + var manifestNode = JsonNode.Parse(manifestText) ?? new JsonObject(); + + // Update top-level id + manifestNode["id"] = blueprintId; + + // Update name if provided + if (!string.IsNullOrWhiteSpace(agentDisplayName)) + { + if (manifestNode["name"] is not JsonObject nameObj) + { + nameObj = new JsonObject(); + manifestNode["name"] = nameObj; + } + else + { + nameObj = (JsonObject)manifestNode["name"]!; + } + + nameObj["short"] = agentDisplayName; + nameObj["full"] = agentDisplayName; + _logger.LogInformation("Updated manifest name to: {Name}", agentDisplayName); + } + + // Update bots[0].botId + if (manifestNode["bots"] is JsonArray bots && bots.Count > 0 && bots[0] is JsonObject botObj) + { + botObj["botId"] = blueprintId; + } + + // Update copilotAgents.customEngineAgents[0].id + if (manifestNode["copilotAgents"] is JsonObject ca && + ca["customEngineAgents"] is JsonArray cea && + cea.Count > 0 && + cea[0] is JsonObject ceObj) + { + ceObj["id"] = blueprintId; + } + + var updatedManifest = manifestNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(manifestPath, updatedManifest); + _logger.LogInformation("Updated manifest.json with blueprint ID: {Id}", blueprintId); + + // Update agenticUserTemplateManifest.json + var templateManifestPath = Path.Combine(workingDirectory, "agenticUserTemplateManifest.json"); + if (!File.Exists(templateManifestPath)) + { + _logger.LogError("Template manifest file not found at {Path}", templateManifestPath); + return false; + } + + var templateText = await File.ReadAllTextAsync(templateManifestPath); + var templateNode = JsonNode.Parse(templateText) ?? new JsonObject(); + + // Update agentIdentityBlueprintId (this replaces the old webApplicationInfo.id logic) + templateNode["agentIdentityBlueprintId"] = blueprintId; + + var updatedTemplate = templateNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(templateManifestPath, updatedTemplate); + _logger.LogInformation("Updated agenticUserTemplateManifest.json with agentIdentityBlueprintId: {Id}", blueprintId); + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update manifest identifiers"); + return false; + } + } + + /// + /// Creates a zip archive containing all manifest files. + /// + /// Directory containing manifest files + /// Path where zip file should be created + /// True if zip creation succeeded + public async Task CreateManifestZipAsync(string workingDirectory, string outputZipPath) + { + try + { + if (File.Exists(outputZipPath)) + { + File.Delete(outputZipPath); + } + + var filesToZip = new[] + { + "manifest.json", + "agenticUserTemplateManifest.json", + "color.png", + "outline.png" + }; + + using var zipStream = new FileStream(outputZipPath, FileMode.Create, FileAccess.ReadWrite); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create); + + foreach (var fileName in filesToZip) + { + var filePath = Path.Combine(workingDirectory, fileName); + if (!File.Exists(filePath)) + { + _logger.LogWarning("Skipping missing file: {File}", fileName); + continue; + } + + var entry = archive.CreateEntry(fileName, CompressionLevel.Optimal); + await using var entryStream = entry.Open(); + await using var fileStream = File.OpenRead(filePath); + await fileStream.CopyToAsync(entryStream); + + _logger.LogInformation("Added {File} to manifest.zip", fileName); + } + + _logger.LogInformation("Created manifest archive: {ZipPath}", outputZipPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create manifest zip"); + return false; + } + } + + /// + /// Validates that all required embedded resources are present in the assembly. + /// + /// True if all resources are present + public bool ValidateEmbeddedResources() + { + var assembly = Assembly.GetExecutingAssembly(); + var requiredResources = new[] + { + $"{ResourcePrefix}manifest.json", + $"{ResourcePrefix}agenticUserTemplateManifest.json", + $"{ResourcePrefix}color.png", + $"{ResourcePrefix}outline.png" + }; + + var allResources = assembly.GetManifestResourceNames(); + var missingResources = requiredResources.Where(r => !allResources.Contains(r)).ToList(); + + if (missingResources.Any()) + { + _logger.LogError("Missing embedded resources: {Resources}", string.Join(", ", missingResources)); + return false; + } + + _logger.LogDebug("All required embedded resources validated"); + return true; + } + + /// + /// Checks if a manifest directory exists and validates its format. + /// + /// Project root path + /// Output parameter with manifest directory path if found + /// True if valid manifest directory exists + public bool TryGetExistingManifestDirectory(string projectPath, out string? manifestDirectory) + { + manifestDirectory = null; + + if (string.IsNullOrWhiteSpace(projectPath) || !Directory.Exists(projectPath)) + { + return false; + } + + var manifestDir = Path.Combine(projectPath, "manifest"); + if (!Directory.Exists(manifestDir)) + { + return false; + } + + manifestDirectory = manifestDir; + return true; + } + + /// + /// Validates that existing manifest files have required structure for updates. + /// + /// Directory containing manifest files + /// True if manifest is compatible with CLI updates + public async Task ValidateManifestFormatAsync(string manifestDirectory) + { + try + { + var manifestPath = Path.Combine(manifestDirectory, "manifest.json"); + var templatePath = Path.Combine(manifestDirectory, "agenticUserTemplateManifest.json"); + + // Check manifest.json exists + if (!File.Exists(manifestPath)) + { + _logger.LogError("manifest.json not found in {Directory}", manifestDirectory); + return false; + } + + // Validate manifest.json structure + var manifestText = await File.ReadAllTextAsync(manifestPath); + var manifestDoc = JsonDocument.Parse(manifestText); + var root = manifestDoc.RootElement; + + // Check for required top-level properties + if (!root.TryGetProperty("id", out _)) + { + _logger.LogError("manifest.json missing required 'id' property"); + return false; + } + + // Check agenticUserTemplateManifest.json if it exists + if (File.Exists(templatePath)) + { + var templateText = await File.ReadAllTextAsync(templatePath); + var templateDoc = JsonDocument.Parse(templateText); + var templateRoot = templateDoc.RootElement; + + if (!templateRoot.TryGetProperty("agentIdentityBlueprintId", out _)) + { + _logger.LogError("agenticUserTemplateManifest.json missing required 'agentIdentityBlueprintId' property"); + return false; + } + } + else + { + _logger.LogWarning("agenticUserTemplateManifest.json not found. Will be created from template."); + } + + _logger.LogInformation("Manifest format validation passed"); + return true; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Invalid JSON format in manifest files"); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to validate manifest format"); + return false; + } + } + + /// + /// Copies existing manifest files to working directory, supplementing with templates as needed. + /// + /// Source manifest directory + /// Destination working directory + /// True if copy succeeded + public async Task CopyAndSupplementManifestAsync(string sourceManifestDirectory, string workingDirectory) + { + try + { + if (!Directory.Exists(workingDirectory)) + { + Directory.CreateDirectory(workingDirectory); + } + + var assembly = Assembly.GetExecutingAssembly(); + + // Copy or extract each required file + var files = new[] + { + "manifest.json", + "agenticUserTemplateManifest.json", + "color.png", + "outline.png" + }; + + foreach (var fileName in files) + { + var sourcePath = Path.Combine(sourceManifestDirectory, fileName); + var destPath = Path.Combine(workingDirectory, fileName); + + if (File.Exists(sourcePath)) + { + // Copy existing file + File.Copy(sourcePath, destPath, overwrite: true); + _logger.LogInformation("Copied existing file: {File}", fileName); + } + else + { + // Extract from embedded resources + var fullResourceName = $"{ResourcePrefix}{fileName}"; + using var stream = assembly.GetManifestResourceStream(fullResourceName); + + if (stream == null) + { + _logger.LogError("Embedded resource not found: {Resource}", fullResourceName); + return false; + } + + using var fileStream = File.Create(destPath); + await stream.CopyToAsync(fileStream); + _logger.LogInformation("Created from template: {File}", fileName); + } + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to copy and supplement manifest files"); + return false; + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Templates/agenticUserTemplateManifest.json b/src/Microsoft.Agents.A365.DevTools.Cli/Templates/agenticUserTemplateManifest.json new file mode 100644 index 00000000..8b97ef5b --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Templates/agenticUserTemplateManifest.json @@ -0,0 +1,6 @@ +{ + "id": "7b0926a6-c4ee-445a-a913-bd054594bd09", + "schemaVersion": "0.1.0-preview", + "agentIdentityBlueprintId": "ae7b12c2-6818-4afe-bf77-201330ff2ed7", + "communicationProtocol": "activityProtocol" +} \ No newline at end of file diff --git a/manifest/color.png b/src/Microsoft.Agents.A365.DevTools.Cli/Templates/color.png similarity index 100% rename from manifest/color.png rename to src/Microsoft.Agents.A365.DevTools.Cli/Templates/color.png diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Templates/manifest.json b/src/Microsoft.Agents.A365.DevTools.Cli/Templates/manifest.json new file mode 100644 index 00000000..2d86d7d4 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Templates/manifest.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/vdevPreview/MicrosoftTeams.schema.json", + "id": "43d5d6b0-5c91-4367-b716-4a78c901a3df", + "name": { + "short": "Your Agent Name", + "full": "Your Agent Full Name" + }, + "description": { + "short": "A brief description of what your agent does.", + "full": "A comprehensive description of your agent's capabilities and purpose. Explain what problems it solves, what data it can access, and how users will interact with it. This description helps users understand when and how to use your agent effectively." + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#9ec9d9", + "version": "1.1.4", + "manifestVersion": "devPreview", + "developer": { + "name": "Microsoft Corporation", + "mpnId": "", + "websiteUrl": "https://go.microsoft.com/fwlink/?LinkId=518028", + "privacyUrl": "https://go.microsoft.com/fwlink/?LinkId=518028", + "termsOfUseUrl": "https://shares.datatransfer.microsoft.com/assets/Microsoft_Terms_of_Use.html" + }, + "agenticUserTemplates": [ + { + "id": "7b0926a6-c4ee-445a-a913-bd054594bd09", + "file": "agenticUserTemplateManifest.json" + } + ] +} \ No newline at end of file diff --git a/manifest/outline.png b/src/Microsoft.Agents.A365.DevTools.Cli/Templates/outline.png similarity index 100% rename from manifest/outline.png rename to src/Microsoft.Agents.A365.DevTools.Cli/Templates/outline.png diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ManifestTemplateServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ManifestTemplateServiceTests.cs new file mode 100644 index 00000000..966b2f28 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ManifestTemplateServiceTests.cs @@ -0,0 +1,694 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO.Compression; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Unit tests for ManifestTemplateService - embedded resource extraction and manifest customization. +/// +public class ManifestTemplateServiceTests : IDisposable +{ + private readonly string _testDirectory; + private readonly ManifestTemplateService _service; + private readonly ILogger _logger; + + public ManifestTemplateServiceTests() + { + _testDirectory = Path.Combine(Path.GetTempPath(), $"manifest-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDirectory); + _logger = Substitute.For>(); + _service = new ManifestTemplateService(_logger); + } + + public void Dispose() + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + + #region Constructor Tests + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + // Act & Assert + var act = () => new ManifestTemplateService(null!); + act.Should().Throw() + .WithParameterName("logger"); + } + + #endregion + + #region ValidateEmbeddedResources Tests + + [Fact] + public void ValidateEmbeddedResources_ReturnsTrue_WhenAllResourcesPresent() + { + // Act + var result = _service.ValidateEmbeddedResources(); + + // Assert + result.Should().BeTrue(); + } + + #endregion + + #region ExtractTemplates Tests + + [Fact] + public void ExtractTemplates_CreatesDirectory_WhenDirectoryDoesNotExist() + { + // Arrange + var newDirectory = Path.Combine(_testDirectory, "new-dir"); + newDirectory.Should().NotBeNull(); + Directory.Exists(newDirectory).Should().BeFalse(); + + // Act + var result = _service.ExtractTemplates(newDirectory); + + // Assert + result.Should().BeTrue(); + Directory.Exists(newDirectory).Should().BeTrue(); + } + + [Fact] + public void ExtractTemplates_ExtractsAllFiles_Successfully() + { + // Act + var result = _service.ExtractTemplates(_testDirectory); + + // Assert + result.Should().BeTrue(); + File.Exists(Path.Combine(_testDirectory, "manifest.json")).Should().BeTrue(); + File.Exists(Path.Combine(_testDirectory, "agenticUserTemplateManifest.json")).Should().BeTrue(); + File.Exists(Path.Combine(_testDirectory, "color.png")).Should().BeTrue(); + File.Exists(Path.Combine(_testDirectory, "outline.png")).Should().BeTrue(); + } + + [Fact] + public void ExtractTemplates_ExtractsValidJson_InManifestFile() + { + // Act + _service.ExtractTemplates(_testDirectory); + + // Assert + var manifestPath = Path.Combine(_testDirectory, "manifest.json"); + var content = File.ReadAllText(manifestPath); + var act = () => JsonDocument.Parse(content); + act.Should().NotThrow(); + } + + [Fact] + public void ExtractTemplates_ExtractsValidJson_InAgenticUserTemplateManifest() + { + // Act + _service.ExtractTemplates(_testDirectory); + + // Assert + var templatePath = Path.Combine(_testDirectory, "agenticUserTemplateManifest.json"); + var content = File.ReadAllText(templatePath); + var act = () => JsonDocument.Parse(content); + act.Should().NotThrow(); + } + + [Fact] + public void ExtractTemplates_ExtractsPngFiles_WithNonZeroSize() + { + // Act + _service.ExtractTemplates(_testDirectory); + + // Assert + var colorPath = Path.Combine(_testDirectory, "color.png"); + var outlinePath = Path.Combine(_testDirectory, "outline.png"); + new FileInfo(colorPath).Length.Should().BeGreaterThan(0); + new FileInfo(outlinePath).Length.Should().BeGreaterThan(0); + } + + #endregion + + #region UpdateManifestIdentifiersAsync Tests + + [Fact] + public async Task UpdateManifestIdentifiersAsync_ReturnsTrue_WhenFilesUpdatedSuccessfully() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var blueprintId = "test-blueprint-id-123"; + + // Act + var result = await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task UpdateManifestIdentifiersAsync_UpdatesTopLevelId_InManifest() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var blueprintId = "new-blueprint-id"; + + // Act + await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId); + + // Assert + var manifestPath = Path.Combine(_testDirectory, "manifest.json"); + var content = await File.ReadAllTextAsync(manifestPath); + var doc = JsonDocument.Parse(content); + doc.RootElement.GetProperty("id").GetString().Should().Be(blueprintId); + } + + [Fact] + public async Task UpdateManifestIdentifiersAsync_UpdatesAgentIdentityBlueprintId_InTemplateManifest() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var blueprintId = "agent-identity-id"; + + // Act + await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId); + + // Assert + var templatePath = Path.Combine(_testDirectory, "agenticUserTemplateManifest.json"); + var content = await File.ReadAllTextAsync(templatePath); + var doc = JsonDocument.Parse(content); + doc.RootElement.GetProperty("agentIdentityBlueprintId").GetString().Should().Be(blueprintId); + } + + [Fact] + public async Task UpdateManifestIdentifiersAsync_UpdatesDisplayName_WhenProvided() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var blueprintId = "test-id"; + var displayName = "Test Agent Display Name"; + + // Act + await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId, displayName); + + // Assert + var manifestPath = Path.Combine(_testDirectory, "manifest.json"); + var content = await File.ReadAllTextAsync(manifestPath); + var doc = JsonDocument.Parse(content); + doc.RootElement.GetProperty("name").GetProperty("short").GetString().Should().Be(displayName); + doc.RootElement.GetProperty("name").GetProperty("full").GetString().Should().Be(displayName); + } + + [Fact] + public async Task UpdateManifestIdentifiersAsync_DoesNotUpdateDisplayName_WhenNull() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var blueprintId = "test-id"; + var manifestPath = Path.Combine(_testDirectory, "manifest.json"); + var originalContent = await File.ReadAllTextAsync(manifestPath); + var originalDoc = JsonDocument.Parse(originalContent); + var originalShortName = originalDoc.RootElement.GetProperty("name").GetProperty("short").GetString(); + + // Act + await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId, null); + + // Assert + var updatedContent = await File.ReadAllTextAsync(manifestPath); + var updatedDoc = JsonDocument.Parse(updatedContent); + updatedDoc.RootElement.GetProperty("name").GetProperty("short").GetString().Should().Be(originalShortName); + } + + [Fact] + public async Task UpdateManifestIdentifiersAsync_ReturnsFalse_WhenManifestNotFound() + { + // Arrange + var blueprintId = "test-id"; + + // Act + var result = await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task UpdateManifestIdentifiersAsync_ReturnsFalse_WhenTemplateManifestNotFound() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var blueprintId = "test-id"; + + // Delete template manifest + File.Delete(Path.Combine(_testDirectory, "agenticUserTemplateManifest.json")); + + // Act + var result = await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task UpdateManifestIdentifiersAsync_UpdatesBotId_WhenBotsArrayExists() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var blueprintId = "bot-blueprint-id"; + + // Act + await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId); + + // Assert + var manifestPath = Path.Combine(_testDirectory, "manifest.json"); + var content = await File.ReadAllTextAsync(manifestPath); + var doc = JsonDocument.Parse(content); + + if (doc.RootElement.TryGetProperty("bots", out var bots) && bots.GetArrayLength() > 0) + { + bots[0].GetProperty("botId").GetString().Should().Be(blueprintId); + } + } + + #endregion + + #region CreateManifestZipAsync Tests + + [Fact] + public async Task CreateManifestZipAsync_CreatesZipFile_Successfully() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var zipPath = Path.Combine(_testDirectory, "test-manifest.zip"); + + // Act + var result = await _service.CreateManifestZipAsync(_testDirectory, zipPath); + + // Assert + result.Should().BeTrue(); + File.Exists(zipPath).Should().BeTrue(); + } + + [Fact] + public async Task CreateManifestZipAsync_ContainsAllRequiredFiles() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var zipPath = Path.Combine(_testDirectory, "test-manifest.zip"); + + // Act + await _service.CreateManifestZipAsync(_testDirectory, zipPath); + + // Assert + using var archive = ZipFile.OpenRead(zipPath); + var entryNames = archive.Entries.Select(e => e.Name).ToList(); + entryNames.Should().Contain("manifest.json"); + entryNames.Should().Contain("agenticUserTemplateManifest.json"); + entryNames.Should().Contain("color.png"); + entryNames.Should().Contain("outline.png"); + } + + [Fact] + public async Task CreateManifestZipAsync_OverwritesExisting_WhenZipAlreadyExists() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var zipPath = Path.Combine(_testDirectory, "test-manifest.zip"); + + // Create existing file + await File.WriteAllTextAsync(zipPath, "dummy content"); + var originalSize = new FileInfo(zipPath).Length; + + // Act + var result = await _service.CreateManifestZipAsync(_testDirectory, zipPath); + + // Assert + result.Should().BeTrue(); + var newSize = new FileInfo(zipPath).Length; + newSize.Should().NotBe(originalSize); + } + + [Fact] + public async Task CreateManifestZipAsync_SkipsMissingFiles_WithWarning() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + File.Delete(Path.Combine(_testDirectory, "color.png")); + var zipPath = Path.Combine(_testDirectory, "test-manifest.zip"); + + // Act + var result = await _service.CreateManifestZipAsync(_testDirectory, zipPath); + + // Assert + result.Should().BeTrue(); + using var archive = ZipFile.OpenRead(zipPath); + var entryNames = archive.Entries.Select(e => e.Name).ToList(); + entryNames.Should().NotContain("color.png"); + } + + [Fact] + public async Task CreateManifestZipAsync_ZipContainsValidJsonFiles() + { + // Arrange + _service.ExtractTemplates(_testDirectory); + var zipPath = Path.Combine(_testDirectory, "test-manifest.zip"); + + // Act + await _service.CreateManifestZipAsync(_testDirectory, zipPath); + + // Assert + using var archive = ZipFile.OpenRead(zipPath); + var manifestEntry = archive.GetEntry("manifest.json"); + manifestEntry.Should().NotBeNull(); + + using var stream = manifestEntry!.Open(); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(); + var act = () => JsonDocument.Parse(content); + act.Should().NotThrow(); + } + + [Fact] + public async Task CreateManifestZipAsync_ReturnsFalse_WhenExceptionOccurs() + { + // Arrange - Use invalid path to trigger exception + var invalidPath = Path.Combine(_testDirectory, "invalid\0path", "test.zip"); + + // Act + var result = await _service.CreateManifestZipAsync(_testDirectory, invalidPath); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region Integration Tests + + [Fact] + public async Task EndToEnd_ExtractUpdateAndZip_WorksTogether() + { + // Arrange + var blueprintId = "e2e-blueprint-id"; + var displayName = "E2E Test Agent"; + var zipPath = Path.Combine(_testDirectory, "final-manifest.zip"); + + // Act - Extract + var extractResult = _service.ExtractTemplates(_testDirectory); + extractResult.Should().BeTrue(); + + // Act - Update + var updateResult = await _service.UpdateManifestIdentifiersAsync(_testDirectory, blueprintId, displayName); + updateResult.Should().BeTrue(); + + // Act - Zip + var zipResult = await _service.CreateManifestZipAsync(_testDirectory, zipPath); + zipResult.Should().BeTrue(); + + // Assert - Verify zip contents + using var archive = ZipFile.OpenRead(zipPath); + var manifestEntry = archive.GetEntry("manifest.json"); + manifestEntry.Should().NotBeNull(); + + using var stream = manifestEntry!.Open(); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(); + var doc = JsonDocument.Parse(content); + + doc.RootElement.GetProperty("id").GetString().Should().Be(blueprintId); + doc.RootElement.GetProperty("name").GetProperty("short").GetString().Should().Be(displayName); + } + + #endregion + + #region TryGetExistingManifestDirectory Tests + + [Fact] + public void TryGetExistingManifestDirectory_ReturnsTrue_WhenManifestDirectoryExists() + { + // Arrange + var projectPath = _testDirectory; + var manifestDir = Path.Combine(projectPath, "manifest"); + Directory.CreateDirectory(manifestDir); + + // Act + var result = _service.TryGetExistingManifestDirectory(projectPath, out var outDir); + + // Assert + result.Should().BeTrue(); + outDir.Should().Be(manifestDir); + } + + [Fact] + public void TryGetExistingManifestDirectory_ReturnsFalse_WhenManifestDirectoryDoesNotExist() + { + // Arrange + var projectPath = _testDirectory; + + // Act + var result = _service.TryGetExistingManifestDirectory(projectPath, out var outDir); + + // Assert + result.Should().BeFalse(); + outDir.Should().BeNull(); + } + + [Fact] + public void TryGetExistingManifestDirectory_ReturnsFalse_WhenProjectPathDoesNotExist() + { + // Arrange + var projectPath = Path.Combine(_testDirectory, "nonexistent"); + + // Act + var result = _service.TryGetExistingManifestDirectory(projectPath, out var outDir); + + // Assert + result.Should().BeFalse(); + outDir.Should().BeNull(); + } + + [Fact] + public void TryGetExistingManifestDirectory_ReturnsFalse_WhenProjectPathIsNull() + { + // Act + var result = _service.TryGetExistingManifestDirectory(null!, out var outDir); + + // Assert + result.Should().BeFalse(); + outDir.Should().BeNull(); + } + + #endregion + + #region ValidateManifestFormatAsync Tests + + [Fact] + public async Task ValidateManifestFormatAsync_ReturnsTrue_WhenBothFilesAreValid() + { + // Arrange + var manifestDir = Path.Combine(_testDirectory, "manifest"); + Directory.CreateDirectory(manifestDir); + + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + @"{""id"": ""test-id"", ""name"": {""short"": ""Test""}}"); + + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "agenticUserTemplateManifest.json"), + @"{""agentIdentityBlueprintId"": ""blueprint-id""}"); + + // Act + var result = await _service.ValidateManifestFormatAsync(manifestDir); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task ValidateManifestFormatAsync_ReturnsTrue_WhenOnlyManifestJsonExists() + { + // Arrange + var manifestDir = Path.Combine(_testDirectory, "manifest"); + Directory.CreateDirectory(manifestDir); + + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + @"{""id"": ""test-id"", ""name"": {""short"": ""Test""}}"); + + // Act + var result = await _service.ValidateManifestFormatAsync(manifestDir); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task ValidateManifestFormatAsync_ReturnsFalse_WhenManifestJsonMissing() + { + // Arrange + var manifestDir = Path.Combine(_testDirectory, "manifest"); + Directory.CreateDirectory(manifestDir); + + // Act + var result = await _service.ValidateManifestFormatAsync(manifestDir); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task ValidateManifestFormatAsync_ReturnsFalse_WhenManifestJsonMissingIdProperty() + { + // Arrange + var manifestDir = Path.Combine(_testDirectory, "manifest"); + Directory.CreateDirectory(manifestDir); + + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + @"{""name"": {""short"": ""Test""}}"); + + // Act + var result = await _service.ValidateManifestFormatAsync(manifestDir); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task ValidateManifestFormatAsync_ReturnsFalse_WhenTemplateManifestMissingRequiredProperty() + { + // Arrange + var manifestDir = Path.Combine(_testDirectory, "manifest"); + Directory.CreateDirectory(manifestDir); + + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + @"{""id"": ""test-id""}"); + + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "agenticUserTemplateManifest.json"), + @"{""someOtherProperty"": ""value""}"); + + // Act + var result = await _service.ValidateManifestFormatAsync(manifestDir); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task ValidateManifestFormatAsync_ReturnsFalse_WhenManifestJsonIsInvalidJson() + { + // Arrange + var manifestDir = Path.Combine(_testDirectory, "manifest"); + Directory.CreateDirectory(manifestDir); + + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + @"{ invalid json }"); + + // Act + var result = await _service.ValidateManifestFormatAsync(manifestDir); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region CopyAndSupplementManifestAsync Tests + + [Fact] + public async Task CopyAndSupplementManifestAsync_CopiesExistingFiles() + { + // Arrange + var sourceDir = Path.Combine(_testDirectory, "source"); + var destDir = Path.Combine(_testDirectory, "dest"); + Directory.CreateDirectory(sourceDir); + + await File.WriteAllTextAsync( + Path.Combine(sourceDir, "manifest.json"), + @"{""id"": ""existing-id""}"); + + // Act + var result = await _service.CopyAndSupplementManifestAsync(sourceDir, destDir); + + // Assert + result.Should().BeTrue(); + File.Exists(Path.Combine(destDir, "manifest.json")).Should().BeTrue(); + var content = await File.ReadAllTextAsync(Path.Combine(destDir, "manifest.json")); + content.Should().Contain("existing-id"); + } + + [Fact] + public async Task CopyAndSupplementManifestAsync_SupplementsMissingFilesFromTemplates() + { + // Arrange + var sourceDir = Path.Combine(_testDirectory, "source"); + var destDir = Path.Combine(_testDirectory, "dest"); + Directory.CreateDirectory(sourceDir); + + // Only create manifest.json, let templates provide the rest + await File.WriteAllTextAsync( + Path.Combine(sourceDir, "manifest.json"), + @"{""id"": ""test-id""}"); + + // Act + var result = await _service.CopyAndSupplementManifestAsync(sourceDir, destDir); + + // Assert + result.Should().BeTrue(); + File.Exists(Path.Combine(destDir, "manifest.json")).Should().BeTrue(); + File.Exists(Path.Combine(destDir, "agenticUserTemplateManifest.json")).Should().BeTrue(); + File.Exists(Path.Combine(destDir, "color.png")).Should().BeTrue(); + File.Exists(Path.Combine(destDir, "outline.png")).Should().BeTrue(); + } + + [Fact] + public async Task CopyAndSupplementManifestAsync_CreatesDestinationDirectory() + { + // Arrange + var sourceDir = Path.Combine(_testDirectory, "source"); + var destDir = Path.Combine(_testDirectory, "dest", "nested"); + Directory.CreateDirectory(sourceDir); + + await File.WriteAllTextAsync( + Path.Combine(sourceDir, "manifest.json"), + @"{""id"": ""test-id""}"); + + // Act + var result = await _service.CopyAndSupplementManifestAsync(sourceDir, destDir); + + // Assert + result.Should().BeTrue(); + Directory.Exists(destDir).Should().BeTrue(); + } + + [Fact] + public async Task CopyAndSupplementManifestAsync_PreservesExistingIconsOverTemplates() + { + // Arrange + var sourceDir = Path.Combine(_testDirectory, "source"); + var destDir = Path.Combine(_testDirectory, "dest"); + Directory.CreateDirectory(sourceDir); + + var customIconContent = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFF }; + await File.WriteAllBytesAsync(Path.Combine(sourceDir, "color.png"), customIconContent); + + // Act + var result = await _service.CopyAndSupplementManifestAsync(sourceDir, destDir); + + // Assert + result.Should().BeTrue(); + var copiedContent = await File.ReadAllBytesAsync(Path.Combine(destDir, "color.png")); + copiedContent.Should().BeEquivalentTo(customIconContent); + } + + #endregion +}