Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
Expand All @@ -32,16 +33,24 @@ const (

// TemplateTypeAzd is a full azd template repository.
TemplateTypeAzd = "azd"

// templateTypeExtensionAIAgent is the discriminator value in the unified
// awesome-azd templates.json manifest that identifies an agent-init
// template. Entries with any other (or empty) templateType belong to the
// standard awesome-azd gallery and are filtered out.
templateTypeExtensionAIAgent = "extension.ai.agent"
)

// AgentTemplate represents an agent template entry from the remote JSON catalog.
// Field names mirror the awesome-azd templates.json schema.
type AgentTemplate struct {
Title string `json:"title"`
Description string `json:"description"`
Language string `json:"language"`
Framework string `json:"framework"`
Source string `json:"source"`
Tags []string `json:"tags"`
Title string `json:"title"`
Description string `json:"description"`
Languages []string `json:"languages"`
ExtensionFramework string `json:"extensionFramework"`
Source string `json:"source"`
ExtensionTags []string `json:"extensionTags"`
TemplateType string `json:"templateType"`
}

// EffectiveType determines the template type by inspecting the source URL.
Expand Down Expand Up @@ -110,14 +119,26 @@ func dirIsEmpty(dir string) (bool, error) {
return len(entries) == 0, nil
}

// fetchAgentTemplates retrieves the agent template catalog from the remote JSON URL.
// fetchAgentTemplates retrieves the agent template catalog from the remote
// awesome-azd manifest URL.
func fetchAgentTemplates(ctx context.Context, httpClient *http.Client) ([]AgentTemplate, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, agentTemplatesURL, nil)
return fetchAgentTemplatesFromURL(ctx, httpClient, agentTemplatesURL)
}

// fetchAgentTemplatesFromURL retrieves the awesome-azd templates manifest from
// the given URL and returns only entries whose templateType marks them as
// agent-init templates. The URL is parameterized to keep this function
// directly testable against an httptest server.
func fetchAgentTemplatesFromURL(
ctx context.Context,
httpClient *http.Client,
url string,
) ([]AgentTemplate, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

//nolint:gosec // URL is the hard-coded agentTemplatesURL constant, not user input
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch agent templates: %w", err)
Expand All @@ -133,12 +154,39 @@ func fetchAgentTemplates(ctx context.Context, httpClient *http.Client) ([]AgentT
return nil, fmt.Errorf("failed to read agent templates response: %w", err)
}

var templates []AgentTemplate
if err := json.Unmarshal(body, &templates); err != nil {
var all []AgentTemplate
if err := json.Unmarshal(body, &all); err != nil {
return nil, fmt.Errorf("failed to parse agent templates: %w", err)
}

return templates, nil
// Keep only agent-init entries. The shared templates.json manifest also
// carries the awesome-azd gallery; those entries must not surface here.
filtered := make([]AgentTemplate, 0, len(all))
for _, t := range all {
if t.TemplateType == templateTypeExtensionAIAgent {
filtered = append(filtered, t)
}
}
Comment thread
hemarina marked this conversation as resolved.

// Always emit the fetched/matched counts to make transition-period and
// misconfiguration issues debuggable.
log.Printf(
"agent templates manifest: fetched %d templateType=%q (source=%s)",
len(filtered), templateTypeExtensionAIAgent, url,
)

// If we received entries but filtered them all out, the manifest is
// almost certainly in the legacy format or the discriminator value has
// changed. Surface that explicitly instead of returning an empty list,
// which the caller cannot distinguish from an intentionally empty manifest.
if len(all) > 0 && len(filtered) == 0 {
return nil, fmt.Errorf(
"agent templates manifest at %s contained %d entries but none had templateType=%q",
url, len(all), templateTypeExtensionAIAgent,
)
}

return filtered, nil
}

// promptAgentTemplate guides the user through language selection and template selection.
Expand Down Expand Up @@ -169,10 +217,11 @@ func promptAgentTemplate(
return nil, fmt.Errorf("no agent templates available")
}

// Prompt for language
// Prompt for language. Values must match the language tokens used in
// the awesome-azd templates.json `languages` field (e.g. "dotnetCsharp").
languageChoices := []*azdext.SelectChoice{
{Label: "Python", Value: "python"},
{Label: "C#", Value: "csharp"},
{Label: "C#", Value: "dotnetCsharp"},
}

langResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{
Expand All @@ -190,10 +239,10 @@ func promptAgentTemplate(

selectedLanguage := languageChoices[*langResp.Value].Value

// Filter templates by selected language
var filtered []AgentTemplate
// Filter templates by selected language (entries can declare multiple).
filtered := make([]AgentTemplate, 0, len(templates))
for _, t := range templates {
if t.Language == selectedLanguage {
if slices.Contains(t.Languages, selectedLanguage) {
filtered = append(filtered, t)
}
Comment thread
hemarina marked this conversation as resolved.
}
Expand All @@ -210,7 +259,7 @@ func promptAgentTemplate(
// Build template choices with framework in label
templateChoices := make([]*azdext.SelectChoice, len(filtered))
for i, t := range filtered {
label := fmt.Sprintf("%s (%s)", t.Title, t.Framework)
label := fmt.Sprintf("%s (%s)", t.Title, t.ExtensionFramework)
templateChoices[i] = &azdext.SelectChoice{
Label: label,
Value: fmt.Sprintf("%d", i),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -89,25 +86,41 @@ func TestEffectiveType(t *testing.T) {
func TestFetchAgentTemplates(t *testing.T) {
t.Parallel()

t.Run("success", func(t *testing.T) {
t.Run("success filters by templateType", func(t *testing.T) {
t.Parallel()

templates := []AgentTemplate{
// Manifest mixes gallery entries (no templateType / wrong templateType)
// with agent-init entries. Only the latter should survive.
manifest := []map[string]any{
{
Title: "Echo Agent",
Language: "python",
Framework: "Agent Framework",
Source: "https://github.com/org/repo/blob/main/echo-agent/agent.yaml",
"title": "Echo Agent",
"languages": []string{"python"},
"extensionFramework": "Agent Framework",
"source": "https://github.com/org/repo/blob/main/echo-agent/agent.yaml",
"templateType": "extension.ai.agent",
},
{
Title: "Calculator Agent",
Language: "csharp",
Framework: "LangGraph",
Source: "Azure-Samples/calculator-agent",
"title": "Calculator Agent",
"languages": []string{"dotnetCsharp"},
"extensionFramework": "LangGraph",
"source": "Azure-Samples/calculator-agent",
"templateType": "extension.ai.agent",
},
{
"title": "Some gallery template",
"languages": []string{"python"},
"source": "Azure-Samples/some-template",
// no templateType -> standard awesome-azd gallery entry
},
{
"title": "Future extension category",
"languages": []string{"python"},
"source": "Azure-Samples/some-other-extension",
"templateType": "extension.something.else",
},
}

data, err := json.Marshal(templates)
data, err := json.Marshal(manifest)
require.NoError(t, err)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -117,14 +130,15 @@ func TestFetchAgentTemplates(t *testing.T) {
}))
defer server.Close()

// Use a custom URL by overriding the HTTP client to redirect
result, err := fetchAgentTemplatesFromURL(t.Context(), server.Client(), server.URL)
require.NoError(t, err)
require.Len(t, result, 2)
require.Equal(t, "Echo Agent", result[0].Title)
require.Equal(t, "python", result[0].Language)
require.Equal(t, []string{"python"}, result[0].Languages)
require.Equal(t, "Agent Framework", result[0].ExtensionFramework)
require.Equal(t, "extension.ai.agent", result[0].TemplateType)
require.Equal(t, "Calculator Agent", result[1].Title)
require.Equal(t, "csharp", result[1].Language)
require.Equal(t, []string{"dotnetCsharp"}, result[1].Languages)
})

t.Run("HTTP error", func(t *testing.T) {
Expand Down Expand Up @@ -167,41 +181,32 @@ func TestFetchAgentTemplates(t *testing.T) {
require.NoError(t, err)
require.Empty(t, result)
})
}

// fetchAgentTemplatesFromURL is a test helper that fetches templates from a custom URL.
func fetchAgentTemplatesFromURL(
ctx context.Context,
httpClient *http.Client,
url string,
) ([]AgentTemplate, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}

//nolint:gosec // URL points to a local httptest server, not user input
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch agent templates: HTTP %d", resp.StatusCode)
}
t.Run("manifest with only gallery entries returns error", func(t *testing.T) {
t.Parallel()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
manifest := []map[string]any{
{
"title": "Some gallery template",
"languages": []string{"python"},
"source": "Azure-Samples/some-template",
},
}
data, err := json.Marshal(manifest)
require.NoError(t, err)

var templates []AgentTemplate
if err := json.Unmarshal(body, &templates); err != nil {
return nil, fmt.Errorf("failed to parse agent templates: %w", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}))
defer server.Close()

return templates, nil
result, err := fetchAgentTemplatesFromURL(t.Context(), server.Client(), server.URL)
require.Error(t, err)
require.Nil(t, result)
require.Contains(t, err.Error(), "extension.ai.agent")
require.Contains(t, err.Error(), "1 entries")
})
}

func TestFindAgentManifest(t *testing.T) {
Expand Down
Loading