diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index fbaf4ef5080..4e74061e0ad 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -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" @@ -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 } func newShowCommand() *cobra.Command { @@ -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) @@ -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() @@ -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) @@ -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() } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 6d419a7f920..5a45044cdd7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -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) } @@ -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) { @@ -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) } @@ -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) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 7767332ae7d..9a1b18efef3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -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{} @@ -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 != "" { @@ -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 @@ -865,8 +887,17 @@ 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 @@ -874,7 +905,9 @@ func (p *AgentServiceTargetProvider) agentPlaygroundUrl(projectResourceId, agent 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 } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 9e2d06e6782..66c1190e549 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -449,3 +449,50 @@ func TestPackage_NoEarlyFailureWithoutACR(t *testing.T) { require.NotNil(t, result) require.NotEmpty(t, result.Artifacts, "expected container artifacts from Package") } + +func TestAgentPlaygroundURL_Valid(t *testing.T) { + t.Parallel() + + // A valid ARM resource ID for a Foundry project + projectResourceID := "/subscriptions/00000000-0000-0000-0000-000000000001/" + + "resourceGroups/my-rg/providers/Microsoft.CognitiveServices/" + + "accounts/my-account/projects/my-project" + + url, err := AgentPlaygroundURL(projectResourceID, "test-agent", "3") + require.NoError(t, err) + require.NotEmpty(t, url) + require.Contains(t, url, "ai.azure.com/nextgen/r/") + require.Contains(t, url, "my-rg") + require.Contains(t, url, "my-account") + require.Contains(t, url, "my-project") + require.Contains(t, url, "test-agent") + require.Contains(t, url, "version=3") +} + +func TestAgentPlaygroundURL_InvalidResourceID(t *testing.T) { + t.Parallel() + + _, err := AgentPlaygroundURL("not-a-valid-resource-id", "agent", "1") + require.Error(t, err) +} + +func TestAgentPlaygroundURL_EmptyInput(t *testing.T) { + t.Parallel() + + // An empty string should fail ARM parsing + _, err := AgentPlaygroundURL("", "agent", "1") + require.Error(t, err) +} + +func TestAgentPlaygroundURL_AccountLevelID(t *testing.T) { + t.Parallel() + + // An account-level resource ID (no /projects/ child) should be rejected + // because it would produce a malformed playground URL. + resourceID := "/subscriptions/00000000-0000-0000-0000-000000000001/" + + "resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account" + + _, err := AgentPlaygroundURL(resourceID, "agent", "1") + require.Error(t, err) + require.Contains(t, err.Error(), "missing parent account") +}