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 05d49738db1..23500705af9 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 @@ -21,6 +21,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/braydonk/yaml" "github.com/drone/envsubst" "github.com/fatih/color" @@ -632,6 +633,8 @@ func (p *AgentServiceTargetProvider) deployArtifacts( "agentName": agentName, "agentVersion": agentVersion, "label": "Agent endpoint", + "clickable": "false", + "note": "For information on invoking the agent, see " + output.WithLinkFormat("https://aka.ms/azd-agents-invoke"), }, }) } diff --git a/cli/azd/pkg/project/artifact.go b/cli/azd/pkg/project/artifact.go index 6e971a985e9..a365c629296 100644 --- a/cli/azd/pkg/project/artifact.go +++ b/cli/azd/pkg/project/artifact.go @@ -14,6 +14,16 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" ) +// Metadata key constants for artifact configuration +const ( + // MetadataKeyClickable controls whether an endpoint should be rendered as a clickable hyperlink. + // Set to "false" to display the URL as plain text (users can still Ctrl+Click in supported terminals). + MetadataKeyClickable = "clickable" + + // MetadataKeyNote adds a note line below the artifact output. + MetadataKeyNote = "note" +) + // ArtifactKind represents well-known artifact types in the Azure Developer CLI type ArtifactKind string @@ -95,14 +105,32 @@ func (a *Artifact) ToString(currentIndentation string) string { discriminator = customDiscriminator } - return fmt.Sprintf( + // Endpoints are clickable (hyperlinked) by default, but can be disabled via metadata + clickable := true + if val, has := a.Metadata[MetadataKeyClickable]; has && strings.EqualFold(val, "false") { + clickable = false + } + + displayLocation := location + if clickable { + displayLocation = output.WithHyperlink(location, location) + } + + result := fmt.Sprintf( "%s- %s: %s %s", currentIndentation, label, - output.WithHyperlink(location, location), + displayLocation, discriminator, ) + // Append note if present + if note, has := a.Metadata[MetadataKeyNote]; has && note != "" { + result += fmt.Sprintf("\n%s %s", currentIndentation, note) + } + + return result + case ArtifactKindContainer: if a.LocationKind == LocationKindRemote { return fmt.Sprintf("%s- Remote Image: %s", currentIndentation, output.WithLinkFormat(location)) diff --git a/cli/azd/pkg/project/artifact_test.go b/cli/azd/pkg/project/artifact_test.go new file mode 100644 index 00000000000..a1d00e87330 --- /dev/null +++ b/cli/azd/pkg/project/artifact_test.go @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// hyperlinkPrefix is the OSC 8 escape sequence prefix used for terminal hyperlinks. +// The actual WithHyperlink function may not emit this in non-terminal environments, +// so we check for its absence to verify non-clickable behavior. +const hyperlinkPrefix = "\x1b]8;;" + +func TestArtifactToString_Endpoint(t *testing.T) { + tests := []struct { + name string + artifact *Artifact + contains []string + shouldBeClickable bool + }{ + { + name: "remote endpoint is clickable by default", + artifact: &Artifact{ + Kind: ArtifactKindEndpoint, + Location: "https://example.com/api", + LocationKind: LocationKindRemote, + }, + contains: []string{ + "- Endpoint:", + "https://example.com/api", + }, + shouldBeClickable: true, + }, + { + name: "remote endpoint with clickable=false is not hyperlinked", + artifact: &Artifact{ + Kind: ArtifactKindEndpoint, + Location: "https://example.com/agents/myagent", + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + MetadataKeyClickable: "false", + }, + }, + contains: []string{ + "- Endpoint:", + "https://example.com/agents/myagent", + }, + shouldBeClickable: false, + }, + { + name: "agent endpoint with custom label and note", + artifact: &Artifact{ + Kind: ArtifactKindEndpoint, + Location: "https://example.com/agents/myagent/versions/1", + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + "label": "Agent endpoint", + MetadataKeyClickable: "false", + MetadataKeyNote: "For information on invoking the agent, see https://aka.ms/azd-agents-invoke", + }, + }, + contains: []string{ + "- Agent endpoint:", + "https://example.com/agents/myagent/versions/1", + "For information on invoking the agent, see https://aka.ms/azd-agents-invoke", + }, + shouldBeClickable: false, + }, + { + name: "local endpoint is clickable by default", + artifact: &Artifact{ + Kind: ArtifactKindEndpoint, + Location: "http://localhost:8080", + LocationKind: LocationKindLocal, + }, + contains: []string{ + "- Endpoint:", + "http://localhost:8080", + }, + shouldBeClickable: true, + }, + { + name: "endpoint with discriminator", + artifact: &Artifact{ + Kind: ArtifactKindEndpoint, + Location: "https://example.com/api", + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + "discriminator": "(primary)", + }, + }, + contains: []string{ + "- Endpoint:", + "https://example.com/api", + "(primary)", + }, + shouldBeClickable: true, + }, + { + name: "clickable=FALSE is case insensitive", + artifact: &Artifact{ + Kind: ArtifactKindEndpoint, + Location: "https://example.com/api", + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + MetadataKeyClickable: "FALSE", + }, + }, + contains: []string{ + "- Endpoint:", + "https://example.com/api", + }, + shouldBeClickable: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.artifact.ToString("") + + for _, expected := range tt.contains { + require.True(t, strings.Contains(result, expected), + "Expected output to contain %q, got: %s", expected, result) + } + + // Check clickability by looking for hyperlink escape sequence + hasHyperlink := strings.Contains(result, hyperlinkPrefix) + if tt.shouldBeClickable { + // In terminal environments, should have hyperlink; in non-terminal, won't have it + // We can't directly test this without mocking terminal, so we just verify the URL is present + require.Contains(t, result, tt.artifact.Location) + } else { + // Should NOT have hyperlink escape sequence + require.False(t, hasHyperlink, + "Expected output NOT to contain hyperlink escape sequence for non-clickable endpoint, got: %q", result) + } + }) + } +} + +func TestArtifactToString_OtherKinds(t *testing.T) { + tests := []struct { + name string + artifact *Artifact + contains string + }{ + { + name: "container remote", + artifact: &Artifact{ + Kind: ArtifactKindContainer, + Location: "myregistry.azurecr.io/myimage:latest", + LocationKind: LocationKindRemote, + }, + contains: "- Remote Image:", + }, + { + name: "container local", + artifact: &Artifact{ + Kind: ArtifactKindContainer, + Location: "myimage:latest", + LocationKind: LocationKindLocal, + }, + contains: "- Container:", + }, + { + name: "archive", + artifact: &Artifact{ + Kind: ArtifactKindArchive, + Location: "/path/to/output.zip", + LocationKind: LocationKindLocal, + }, + contains: "- Package Output:", + }, + { + name: "directory", + artifact: &Artifact{ + Kind: ArtifactKindDirectory, + Location: "/path/to/build", + LocationKind: LocationKindLocal, + }, + contains: "- Build Output:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.artifact.ToString("") + require.Contains(t, result, tt.contains) + }) + } +} + +func TestArtifactToString_EmptyLocation(t *testing.T) { + artifact := &Artifact{ + Kind: ArtifactKindEndpoint, + Location: "", + LocationKind: LocationKindRemote, + } + + result := artifact.ToString("") + require.Empty(t, result) +}