diff --git a/cli/azd/extensions/azure.ai.agents/ci-test.ps1 b/cli/azd/extensions/azure.ai.agents/ci-test.ps1 new file mode 100644 index 00000000000..2e66dea3138 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/ci-test.ps1 @@ -0,0 +1,27 @@ +$gopath = go env GOPATH +$gotestsumBinary = "gotestsum" +if ($IsWindows) { + $gotestsumBinary += ".exe" +} +$gotestsum = Join-Path $gopath "bin" $gotestsumBinary + +Write-Host "Running unit tests..." + +if (Test-Path $gotestsum) { + # Use gotestsum for better output formatting and summary + & $gotestsum --format testname -- ./... -count=1 +} else { + # Fallback to go test if gotestsum is not installed + Write-Host "gotestsum not found, using go test..." -ForegroundColor Yellow + go test ./... -v -count=1 +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Tests failed with exit code: $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "All tests passed!" -ForegroundColor Green +exit 0 diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 8a322daa452..73591f94ff9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1087,7 +1087,7 @@ func (a *InitAction) downloadAgentYaml( // Parse and validate the YAML content against AgentManifest structure agentManifest, err := agent_yaml.LoadAndValidateAgentManifest(content) if err != nil { - return nil, "", fmt.Errorf("AgentManifest %w", err) + return nil, "", err } fmt.Println("✓ YAML content successfully validated against AgentManifest format") diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index c4d5f2ad4a7..e81b3812e80 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -22,13 +22,13 @@ func LoadAndValidateAgentManifest(manifestYamlContent []byte) (*AgentManifest, e agentDef, err := ExtractAgentDefinition(manifestYamlContent) if err != nil { - return nil, fmt.Errorf("YAML content does not conform to AgentManifest format: %w", err) + return nil, err } manifest.Template = agentDef resourceDefs, err := ExtractResourceDefinitions(manifestYamlContent) if err != nil { - return nil, fmt.Errorf("YAML content does not conform to AgentManifest format: %w", err) + return nil, err } manifest.Resources = resourceDefs @@ -47,8 +47,27 @@ func ExtractAgentDefinition(manifestYamlContent []byte) (any, error) { return nil, fmt.Errorf("YAML content is not valid: %w", err) } - template := genericManifest["template"].(map[string]interface{}) - templateBytes, _ := yaml.Marshal(template) + // Handle manifest format with "template" field + var templateBytes []byte + + if templateValue, exists := genericManifest["template"]; exists && templateValue != nil { + // Manifest format with "template" field + template, ok := templateValue.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("template field must be a map, got %T", templateValue) + } + if len(template) == 0 { + return nil, fmt.Errorf("YAML content does not conform to AgentManifest format: template field is empty. See https://microsoft.github.io/AgentSchema/reference/agentmanifest for the expected format and https://github.com/microsoft-foundry/foundry-samples for examples") + } + var err error + templateBytes, err = yaml.Marshal(template) + if err != nil { + return nil, fmt.Errorf("failed to marshal template: %w", err) + } + } else { + // "template" field not found - return error + return nil, fmt.Errorf("YAML content does not conform to AgentManifest format: must contain 'template' field. See https://microsoft.github.io/AgentSchema/reference/agentmanifest for the expected format and https://github.com/microsoft-foundry/foundry-samples for examples") + } var agentDef AgentDefinition if err := yaml.Unmarshal(templateBytes, &agentDef); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go new file mode 100644 index 00000000000..3679d528713 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "strings" + "testing" +) + +// TestExtractAgentDefinition_WithTemplateField tests parsing YAML with a template field (manifest format) +func TestExtractAgentDefinition_WithTemplateField(t *testing.T) { + yamlContent := []byte(` +name: test-manifest +template: + kind: hosted + name: test-agent + description: Test agent with template field + protocols: + - protocol: responses + version: v1 +`) + + agent, err := ExtractAgentDefinition(yamlContent) + if err != nil { + t.Fatalf("ExtractAgentDefinition failed: %v", err) + } + + containerAgent, ok := agent.(ContainerAgent) + if !ok { + t.Fatalf("Expected ContainerAgent, got %T", agent) + } + + if containerAgent.Name != "test-agent" { + t.Errorf("Expected name 'test-agent', got '%s'", containerAgent.Name) + } + + if containerAgent.Kind != AgentKindHosted { + t.Errorf("Expected kind 'hosted', got '%s'", containerAgent.Kind) + } +} + +// TestExtractAgentDefinition_EmptyTemplateField tests that an empty or null template field returns an error +func TestExtractAgentDefinition_EmptyTemplateField(t *testing.T) { + testCases := []struct { + name string + yaml string + }{ + { + name: "null template field", + yaml: ` +name: test-manifest +template: null +`, + }, + { + name: "empty template field", + yaml: ` +name: test-manifest +template: {} +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := ExtractAgentDefinition([]byte(tc.yaml)) + if err == nil { + t.Fatal("Expected error for empty/null template field, got nil") + } + + // The error should indicate the template field issue + expectedMsg := "YAML content does not conform to AgentManifest format" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) + } + }) + } +} + +// TestExtractAgentDefinition_WithoutTemplateField tests that YAML without template field returns an error +func TestExtractAgentDefinition_WithoutTemplateField(t *testing.T) { + yamlContent := []byte(` +kind: hosted +name: lego-social-media-agent +description: An AI-powered social media content generator for LEGO products. +metadata: + authors: + - LEGO Social Media Team + tags: + - Social Media + - Content Generation +protocols: + - protocol: responses + version: v1 +environment_variables: + - name: POSTGRES_SERVER + value: ${POSTGRES_SERVER} + - name: POSTGRES_DATABASE + value: ${POSTGRES_DATABASE} +`) + + _, err := ExtractAgentDefinition(yamlContent) + if err == nil { + t.Fatal("Expected error for YAML without template field, got nil") + } + + expectedMsg := "must contain 'template' field" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) + } +} + +// TestExtractAgentDefinition_InvalidTemplate tests that an invalid template field returns an error +func TestExtractAgentDefinition_InvalidTemplate(t *testing.T) { + yamlContent := []byte(` +name: test-manifest +template: "this is not a map" +`) + + _, err := ExtractAgentDefinition(yamlContent) + if err == nil { + t.Fatal("Expected error for invalid template field, got nil") + } + + expectedMsg := "template field must be a map" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) + } +} + +// TestExtractAgentDefinition_MissingTemplateField tests that YAML without template field returns an error +func TestExtractAgentDefinition_MissingTemplateField(t *testing.T) { + yamlContent := []byte(` +name: test-agent +description: Test agent without template field +`) + + _, err := ExtractAgentDefinition(yamlContent) + if err == nil { + t.Fatal("Expected error for YAML without template field, got nil") + } + + expectedMsg := "must contain 'template' field" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) + } +} + +// TestLoadAndValidateAgentManifest_WithoutTemplateField tests that YAML without template field returns an error +func TestLoadAndValidateAgentManifest_WithoutTemplateField(t *testing.T) { + yamlContent := []byte(` +kind: hosted +name: test-standalone-agent +description: A standalone agent definition +protocols: + - protocol: responses + version: v1 +`) + + _, err := LoadAndValidateAgentManifest(yamlContent) + if err == nil { + t.Fatal("Expected error for YAML without template field, got nil") + } + + expectedMsg := "must contain 'template' field" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) + } +} + +// TestExtractAgentDefinition_IssueExample tests the exact YAML from the GitHub issue +func TestExtractAgentDefinition_IssueExample(t *testing.T) { + // This is the exact YAML from the GitHub issue that was causing the panic + // It should now return a proper error message instead of panicking + yamlContent := []byte(`# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: lego-social-media-agent +description: | + An AI-powered social media content generator for LEGO products. +metadata: + authors: + - LEGO Social Media Team + example: + - content: Generate a Twitter post about Star Wars LEGO sets + role: user + tags: + - Social Media + - Content Generation +protocols: + - protocol: responses + version: v1 +environment_variables: + - name: POSTGRES_SERVER + value: ${POSTGRES_SERVER} + - name: POSTGRES_DATABASE + value: ${POSTGRES_DATABASE} +`) + + _, err := ExtractAgentDefinition(yamlContent) + if err == nil { + t.Fatal("Expected error for YAML without template field, got nil") + } + + expectedMsg := "must contain 'template' field" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) + } + + // Test full validation flow - should also return error + _, err = LoadAndValidateAgentManifest(yamlContent) + if err == nil { + t.Fatal("Expected error from LoadAndValidateAgentManifest for YAML without template field, got nil") + } + + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml b/cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml index 279897063b1..6458dc82dfb 100644 --- a/cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml +++ b/cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml @@ -1,4 +1,4 @@ -agent: +template: kind: prompt name: Learn French Agent description: |- diff --git a/cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml b/cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml index 59d98824513..1abf0813084 100644 --- a/cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml +++ b/cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml @@ -1,4 +1,4 @@ -agent: +template: kind: prompt name: github-agent description: An agent leveraging github-mcp to assist with GitHub-related tasks. diff --git a/eng/pipelines/release-ext-azure-ai-agents.yml b/eng/pipelines/release-ext-azure-ai-agents.yml index e7d75fee630..264bc42797e 100644 --- a/eng/pipelines/release-ext-azure-ai-agents.yml +++ b/eng/pipelines/release-ext-azure-ai-agents.yml @@ -31,4 +31,3 @@ extends: AzdExtensionId: azure.ai.agents SanitizedExtensionId: azure-ai-agents AzdExtensionDirectory: cli/azd/extensions/azure.ai.agents - SkipTests: true