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
126 changes: 119 additions & 7 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"slices"
"text/tabwriter"
"time"

"azureaiagent/internal/pkg/agents/agent_api"
projectpkg "azureaiagent/internal/project"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
Expand All @@ -25,7 +28,12 @@ type showFlags struct {
// ShowAction handles the execution of the show command.
type ShowAction struct {
*AgentContext
flags *showFlags
flags *showFlags
azdClient *azdext.AzdClient
envName string
// serviceKey is the uppercase/underscored form of the service name,
// used to look up per-service env vars (e.g. AGENT_{KEY}_RESPONSES_ENDPOINT).
serviceKey string
}
Comment thread
Nathandrake229 marked this conversation as resolved.

func newShowCommand() *cobra.Command {
Expand Down Expand Up @@ -87,9 +95,18 @@ configuration and the current azd environment. Optionally specify the service na
return err
}

// Resolve the current environment name for env var lookups
var envName string
if envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil {
envName = envResp.Environment.Name
}

action := &ShowAction{
AgentContext: agentContext,
flags: flags,
azdClient: azdClient,
envName: envName,
serviceKey: toServiceKey(info.ServiceName),
}

return action.Run(ctx)
Expand All @@ -101,6 +118,13 @@ configuration and the current azd environment. Optionally specify the service na
return cmd
}

// showResult wraps the API response with additional computed links.
type showResult struct {
*agent_api.AgentVersionObject
PlaygroundURL string `json:"playground_url,omitempty"`
Endpoints map[string]string `json:"agent_endpoints,omitempty"`
}

// Run executes the show command logic.
func (a *ShowAction) Run(ctx context.Context) error {
agentClient, err := a.NewClient()
Expand All @@ -115,28 +139,108 @@ func (a *ShowAction) Run(ctx context.Context) error {
return fmt.Errorf("failed to get agent version: %w", err)
}

result := &showResult{AgentVersionObject: version}

// Resolve playground URL (best-effort)
result.PlaygroundURL = a.resolvePlaygroundURL(ctx)

// Resolve deployed endpoint URLs from env vars (best-effort)
result.Endpoints = a.resolveEndpointURLs(ctx)

switch a.flags.output {
case "table":
return printAgentVersionTable(version)
return printShowResultTable(result)
default:
return printAgentVersionJSON(version)
return printShowResultJSON(result)
}
}

// resolvePlaygroundURL reads AZURE_AI_PROJECT_ID from the azd environment
// and constructs the Foundry portal playground URL. Returns empty string on failure.
func (a *ShowAction) resolvePlaygroundURL(ctx context.Context) string {
if a.azdClient == nil || a.envName == "" {
return ""
}

v, err := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: a.envName,
Key: "AZURE_AI_PROJECT_ID",
})
if err != nil {
return ""
}
if v.Value == "" {
return ""
}

playgroundURL, err := projectpkg.AgentPlaygroundURL(v.Value, a.Name, a.Version)
if err != nil {
return ""
}

return playgroundURL
}

// resolveEndpointURLs reads per-protocol endpoint env vars
// (e.g. AGENT_{KEY}_RESPONSES_ENDPOINT) from the azd environment.
// Falls back to the legacy single-endpoint var (AGENT_{KEY}_ENDPOINT)
// for environments deployed before per-protocol vars were introduced.
// Returns a map of protocol label to URL for endpoints that are set.
func (a *ShowAction) resolveEndpointURLs(ctx context.Context) map[string]string {
if a.azdClient == nil || a.envName == "" || a.serviceKey == "" {
return nil
}

// Use the canonical protocol list from the project package
protocolSuffixes := projectpkg.DisplayableProtocolEnvSuffixes()

endpoints := make(map[string]string)
for _, ps := range protocolSuffixes {
key := fmt.Sprintf("AGENT_%s_%s_ENDPOINT", a.serviceKey, ps.Suffix)
v, err := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: a.envName,
Key: key,
})
if err != nil || v.Value == "" {
continue
}
endpoints[ps.Label] = v.Value
}

// Fall back to legacy single-endpoint var for older deployments
if len(endpoints) == 0 {
legacyKey := fmt.Sprintf("AGENT_%s_ENDPOINT", a.serviceKey)
v, err := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: a.envName,
Key: legacyKey,
})
if err == nil && v.Value != "" {
endpoints["Agent"] = v.Value
}
}

if len(endpoints) == 0 {
return nil
}
return endpoints
}

func printAgentVersionJSON(version *agent_api.AgentVersionObject) error {
jsonBytes, err := json.MarshalIndent(version, "", " ")
func printShowResultJSON(result *showResult) error {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal agent version to JSON: %w", err)
return fmt.Errorf("failed to marshal show result to JSON: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}

func printAgentVersionTable(version *agent_api.AgentVersionObject) error {
func printShowResultTable(result *showResult) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "FIELD\tVALUE")
fmt.Fprintln(w, "-----\t-----")

version := result.AgentVersionObject

fmt.Fprintf(w, "ID\t%s\n", version.ID)
fmt.Fprintf(w, "Name\t%s\n", version.Name)
fmt.Fprintf(w, "Version\t%s\n", version.Version)
Expand Down Expand Up @@ -169,5 +273,13 @@ func printAgentVersionTable(version *agent_api.AgentVersionObject) error {
fmt.Fprintf(w, "Metadata[%s]\t%s\n", k, v)
}

// Display playground and endpoint links
if result.PlaygroundURL != "" {
fmt.Fprintf(w, "Playground URL\t%s\n", result.PlaygroundURL)
}
for _, label := range slices.Sorted(maps.Keys(result.Endpoints)) {
fmt.Fprintf(w, "Endpoint (%s)\t%s\n", label, result.Endpoints[label])
}

return w.Flush()
}
83 changes: 66 additions & 17 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ func TestPrintAgentVersionJSON(t *testing.T) {
CreatedAt: 1735689600, // 2025-01-01T00:00:00Z
}

err := printAgentVersionJSON(version)
result := &showResult{AgentVersionObject: version}
err := printShowResultJSON(result)
require.NoError(t, err)
}

Expand Down Expand Up @@ -113,30 +114,69 @@ func TestPrintAgentVersionJSON_Format(t *testing.T) {
AgentGUID: "guid-1234",
}

jsonBytes, err := json.MarshalIndent(version, "", " ")
result := &showResult{
AgentVersionObject: version,
PlaygroundURL: "https://ai.azure.com/nextgen/r/test/build/agents/test-agent/build?version=2",
Endpoints: map[string]string{
"Responses": "https://acct.services.ai.azure.com/api/projects/proj/agents/test-agent/endpoint/protocols/openai/responses?api-version=2025-11-15-preview",
},
}

jsonBytes, err := json.MarshalIndent(result, "", " ")
require.NoError(t, err)

var result map[string]any
err = json.Unmarshal(jsonBytes, &result)
var raw map[string]any
err = json.Unmarshal(jsonBytes, &raw)
require.NoError(t, err)

assert.Equal(t, "agent.version", result["object"])
assert.Equal(t, "ver-456", result["id"])
assert.Equal(t, "test-agent", result["name"])
assert.Equal(t, "2", result["version"])
assert.Equal(t, "A test agent", result["description"])
assert.Equal(t, "active", result["status"])
assert.Equal(t, "guid-1234", result["agent_guid"])
metadata := result["metadata"].(map[string]any)
assert.Equal(t, "agent.version", raw["object"])
assert.Equal(t, "ver-456", raw["id"])
assert.Equal(t, "test-agent", raw["name"])
assert.Equal(t, "2", raw["version"])
assert.Equal(t, "A test agent", raw["description"])
assert.Equal(t, "active", raw["status"])
assert.Equal(t, "guid-1234", raw["agent_guid"])
metadata := raw["metadata"].(map[string]any)
assert.Equal(t, "prod", metadata["env"])
instanceIdentity := result["instance_identity"].(map[string]any)
instanceIdentity := raw["instance_identity"].(map[string]any)
assert.Equal(t, "inst-pid", instanceIdentity["principal_id"])
assert.Equal(t, "inst-cid", instanceIdentity["client_id"])
blueprint := result["blueprint"].(map[string]any)
blueprint := raw["blueprint"].(map[string]any)
assert.Equal(t, "bp-pid", blueprint["principal_id"])
blueprintRef := result["blueprint_reference"].(map[string]any)
blueprintRef := raw["blueprint_reference"].(map[string]any)
assert.Equal(t, "ManagedAgentIdentityBlueprint", blueprintRef["type"])
assert.Equal(t, "test-agent-abc12", blueprintRef["blueprint_id"])

// Verify new fields
assert.Equal(t,
"https://ai.azure.com/nextgen/r/test/build/agents/test-agent/build?version=2",
raw["playground_url"],
)
endpoints := raw["agent_endpoints"].(map[string]any)
assert.Contains(t, endpoints, "Responses")
}

func TestPrintAgentVersionJSON_NoLinks(t *testing.T) {
version := &agent_api.AgentVersionObject{
Object: "agent.version",
ID: "ver-789",
Name: "my-agent",
Version: "1",
}

result := &showResult{AgentVersionObject: version}
jsonBytes, err := json.MarshalIndent(result, "", " ")
require.NoError(t, err)

var raw map[string]any
err = json.Unmarshal(jsonBytes, &raw)
require.NoError(t, err)

// playground_url and endpoints should be absent when empty
_, hasPlayground := raw["playground_url"]
assert.False(t, hasPlayground, "playground_url should be omitted when empty")
_, hasEndpoints := raw["agent_endpoints"]
assert.False(t, hasEndpoints, "agent_endpoints should be omitted when nil")
}

func TestPrintAgentVersionTable(t *testing.T) {
Expand Down Expand Up @@ -165,7 +205,15 @@ func TestPrintAgentVersionTable(t *testing.T) {
},
}

err := printAgentVersionTable(version)
result := &showResult{
AgentVersionObject: version,
PlaygroundURL: "https://ai.azure.com/playground",
Endpoints: map[string]string{
"Responses": "https://example.com/responses",
},
}

err := printShowResultTable(result)
require.NoError(t, err)
}

Expand All @@ -177,6 +225,7 @@ func TestPrintAgentVersionTable_MinimalFields(t *testing.T) {
Version: "1",
}

err := printAgentVersionTable(version)
result := &showResult{AgentVersionObject: version}
err := printShowResultTable(result)
require.NoError(t, err)
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ var displayableProtocols = []displayableProtocolEntry{
{Protocol: agent_api.AgentProtocolInvocations, URLPath: "invocations", EnvSuffix: "INVOCATIONS"},
}

// ProtocolEnvSuffix pairs a user-facing label with the env var suffix
// used in AGENT_{KEY}_{SUFFIX}_ENDPOINT variables.
type ProtocolEnvSuffix struct {
Label string // e.g. "Responses"
Suffix string // e.g. "RESPONSES"
}

// DisplayableProtocolEnvSuffixes returns the label/suffix pairs for all
// displayable protocols. This is the single source of truth shared by
// deployment (registerAgentEnvironmentVariables) and the show command.
func DisplayableProtocolEnvSuffixes() []ProtocolEnvSuffix {
result := make([]ProtocolEnvSuffix, len(displayableProtocols))
for i, dp := range displayableProtocols {
result[i] = ProtocolEnvSuffix{
Label: string(dp.Protocol),
Suffix: dp.EnvSuffix,
}
}
return result
}

// Ensure AgentServiceTargetProvider implements ServiceTargetProvider interface
var _ azdext.ServiceTargetProvider = &AgentServiceTargetProvider{}

Expand Down Expand Up @@ -767,7 +788,7 @@ func (p *AgentServiceTargetProvider) deployArtifacts(

// Add playground URL
if projectResourceID != "" {
playgroundUrl, err := p.agentPlaygroundUrl(projectResourceID, agentName, agentVersion)
playgroundUrl, err := AgentPlaygroundURL(projectResourceID, agentName, agentVersion)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to generate agent playground link")
} else if playgroundUrl != "" {
Expand Down Expand Up @@ -850,11 +871,12 @@ func agentInvocationEndpoints(
return endpoints
}

// agentPlaygroundUrl constructs a URL to the agent playground in the Foundry portal
func (p *AgentServiceTargetProvider) agentPlaygroundUrl(projectResourceId, agentName, agentVersion string) (string, error) {
resourceId, err := arm.ParseResourceID(projectResourceId)
// AgentPlaygroundURL constructs a URL to the agent playground in the Foundry portal.
// It parses the ARM resource ID to extract subscription, resource group, account, and project info.
func AgentPlaygroundURL(projectResourceID, agentName, agentVersion string) (string, error) {
resourceId, err := arm.ParseResourceID(projectResourceID)
if err != nil {
return "", err
return "", fmt.Errorf("failed to parse project resource ID: %w", err)
}

// Encode subscription ID as base64 without padding for URL
Expand All @@ -865,16 +887,27 @@ func (p *AgentServiceTargetProvider) agentPlaygroundUrl(projectResourceId, agent
}

resourceGroup := resourceId.ResourceGroupName
if resourceId.Parent == nil {
return "", fmt.Errorf("invalid Microsoft Foundry project ID: %s", projectResourceId)

// Validate that the resource ID represents a Foundry project (has a parent account).
// Account-level IDs (no /projects/ child) would produce malformed playground URLs.
// For project-level IDs, Parent.Name is the account; for account-level IDs,
// Parent.Name is the resource group — we distinguish by checking ResourceType.
if resourceId.Parent == nil ||
!strings.Contains(string(resourceId.ResourceType.Type), "/") {
return "", fmt.Errorf(
"resource ID does not represent a Foundry project (missing parent account): %s",
projectResourceID,
)
}

accountName := resourceId.Parent.Name
projectName := resourceId.Name

url := fmt.Sprintf(
"https://ai.azure.com/nextgen/r/%s,%s,,%s,%s/build/agents/%s/build?version=%s",
encodedSubscriptionId, resourceGroup, accountName, projectName, agentName, agentVersion)
encodedSubscriptionId, resourceGroup, accountName, projectName,
agentName, agentVersion,
)
return url, nil
}

Expand Down
Loading
Loading