diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 260983190ab..53e86eef85d 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -77,6 +77,7 @@ words: - underscoped - unparseable - Vnext + - VOICELIVE - webp # Doctor / next-step terms - nextstep diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json index 40b60b50e60..c767d8691fb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json @@ -21,6 +21,7 @@ "switzerlandnorth", "uksouth", "westus", + "westus2", "westus3" ] } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 158474be4ee..4f7c0a650b7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -657,6 +657,49 @@ func preBuiltImageForInit(agentManifest *agent_yaml.AgentManifest, flagImage str return strings.TrimSpace(ca.Image) } +func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.ProtocolVersionRecord, error) { + if len(flagProtocols) == 0 { + return []agent_yaml.ProtocolVersionRecord{{Protocol: "responses", Version: "2.0.0"}}, nil + } + protocols, err := resolveKnownProtocols(flagProtocols) + if err != nil { + return nil, err + } + records := make([]agent_yaml.ProtocolVersionRecord, 0, len(protocols)) + for _, p := range protocols { + records = append(records, agent_yaml.ProtocolVersionRecord{Protocol: p.Name, Version: p.Version}) + } + return records, nil +} + +func resolveKnownProtocols(flagProtocols []string) ([]protocolInfo, error) { + versionOf := make(map[string]string, len(knownProtocols)) + for _, p := range knownProtocols { + versionOf[p.Name] = p.Version + } + + seen := make(map[string]bool, len(flagProtocols)) + protocols := make([]protocolInfo, 0, len(flagProtocols)) + for _, name := range flagProtocols { + if seen[name] { + continue + } + seen[name] = true + + version, ok := versionOf[name] + if !ok { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("unknown protocol %q; supported values: %s", name, knownProtocolNames()), + "choose one of the supported protocols or omit --protocol to use the default", + ) + } + protocols = append(protocols, protocolInfo{Name: name, Version: version}) + } + + return protocols, nil +} + // synthesizeImageManifestFile writes a minimal hosted container agent manifest to a // temporary file for the bring-your-own-image flow (--image without --manifest). // Routing through the manifest path lets init skip template/language selection and code @@ -664,8 +707,12 @@ func preBuiltImageForInit(agentManifest *agent_yaml.AgentManifest, flagImage str // the temp directory; callers should defer it. The image is intentionally not embedded // in the temporary manifest; init writes --image to the generated azure.yaml service's // top-level image field. -func synthesizeImageManifestFile(agentName, image string) (string, func(), error) { +func synthesizeImageManifestFile(agentName, image string, flagProtocols []string) (string, func(), error) { noop := func() {} + protocols, err := protocolRecordsForImageManifest(flagProtocols) + if err != nil { + return "", noop, err + } tmpDir, err := os.MkdirTemp("", "azd-agent-image-") if err != nil { @@ -673,15 +720,18 @@ func synthesizeImageManifestFile(agentName, image string) (string, func(), error } cleanup := func() { _ = os.RemoveAll(tmpDir) } + protocolDocs := make([]map[string]any, 0, len(protocols)) + for _, p := range protocols { + protocolDocs = append(protocolDocs, map[string]any{"protocol": p.Protocol, "version": p.Version}) + } + doc := map[string]any{ "name": agentName, "template": map[string]any{ "kind": string(agent_yaml.AgentKindHosted), "name": agentName, "description": fmt.Sprintf("Hosted container agent using pre-built image %s", image), - "protocols": []map[string]any{ - {"protocol": "responses", "version": "2.0.0"}, - }, + "protocols": protocolDocs, }, } @@ -923,7 +973,6 @@ func runInitFromManifest( if err != nil { return err } - // Create credential with whatever tenant is available (may be empty → default tenant) credential, err := azidentity.NewAzureDeveloperCLICredential( &azidentity.AzureDeveloperCLICredentialOptions{ @@ -1114,7 +1163,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "pass --agent-name (or provide --manifest with the agent definition)", ) } - manifestPath, cleanup, err := synthesizeImageManifestFile(flags.agentName, flags.image) + manifestPath, cleanup, err := synthesizeImageManifestFile(flags.agentName, flags.image, flags.protocols) if err != nil { return err } @@ -1481,7 +1530,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Directory to download the agent definition to (defaults to 'src/')") cmd.Flags().StringSliceVar(&flags.protocols, "protocol", nil, - "Protocols supported by the agent (e.g., 'responses', 'invocations'). Can be specified multiple times.") + fmt.Sprintf("Protocols supported by the agent (%s). Can be specified multiple times.", knownProtocolNames())) cmd.Flags().StringVar(&flags.deployMode, "deploy-mode", "", "Deployment mode: 'container' (Docker image) or 'code' (ZIP upload). Defaults to 'code' for Python/.NET projects in --no-prompt.") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 7eb3264bd54..b5f31fc7f9c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -279,7 +279,6 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) } a.azureContext = azureContext } - // TODO: Prompt user for agent kind agentKind := agent_yaml.AgentKindHosted @@ -906,6 +905,7 @@ type protocolInfo struct { var knownProtocols = []protocolInfo{ {Name: "responses", Version: "2.0.0"}, {Name: "invocations", Version: "1.0.0"}, + {Name: "invocations_ws", Version: "2.0.0"}, // "activity" is the canonical protocol name (legacy alias: "activity_protocol"). // The version selects the platform's internal container route ("v1"/"1.0.0" -> // /api/messages, "2.0.0" -> /activity/messages), but that hop is Bot Service -> diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index 675a67d72fd..c7d3166c748 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -597,6 +598,13 @@ func TestPromptProtocols_FlagValues(t *testing.T) { {Protocol: "invocations", Version: "1.0.0"}, }, }, + { + name: "invocations_ws only", + flagProtocols: []string{"invocations_ws"}, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + }, { name: "both protocols", flagProtocols: []string{"responses", "invocations"}, @@ -696,6 +704,9 @@ func TestKnownProtocolNames(t *testing.T) { if !strings.Contains(result, "invocations") { t.Errorf("knownProtocolNames() = %q, want to contain 'invocations'", result) } + if !strings.Contains(result, "invocations_ws") { + t.Errorf("knownProtocolNames() = %q, want to contain 'invocations_ws'", result) + } if !strings.Contains(result, "activity") { t.Errorf("knownProtocolNames() = %q, want to contain 'activity'", result) } @@ -736,6 +747,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: true}, {Value: "invocations", Label: "invocations", Selected: true}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -751,6 +763,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: true}, {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -758,6 +771,25 @@ func TestPromptProtocols_Interactive(t *testing.T) { {Protocol: "responses", Version: "2.0.0"}, }, }, + { + name: "websocket protocol selected", + multiSelectFn: func( + _ context.Context, + _ *azdext.MultiSelectRequest, + _ ...grpc.CallOption, + ) (*azdext.MultiSelectResponse, error) { + return &azdext.MultiSelectResponse{ + Values: []*azdext.MultiSelectChoice{ + {Value: "responses", Label: "responses", Selected: false}, + {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: true}, + }, + }, nil + }, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + }, { name: "user cancellation", multiSelectFn: func(_ context.Context, _ *azdext.MultiSelectRequest, _ ...grpc.CallOption) (*azdext.MultiSelectResponse, error) { @@ -773,6 +805,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: false}, {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -819,6 +852,33 @@ func TestPromptProtocols_Interactive(t *testing.T) { } } +func TestPromptProtocols_ChoicesIncludeInvocationsWsWithoutChangingDefault(t *testing.T) { + t.Parallel() + + client := &fakePromptClient{multiSelectFn: func( + _ context.Context, + in *azdext.MultiSelectRequest, + _ ...grpc.CallOption, + ) (*azdext.MultiSelectResponse, error) { + choices := in.Options.Choices + require.GreaterOrEqual(t, len(choices), 3) + require.Equal(t, "responses", choices[0].Value) + require.True(t, choices[0].Selected) + require.Equal(t, "invocations", choices[1].Value) + require.False(t, choices[1].Selected) + require.Equal(t, "invocations_ws", choices[2].Value) + require.False(t, choices[2].Selected) + + return &azdext.MultiSelectResponse{Values: choices}, nil + }} + + got, err := promptProtocols(t.Context(), client, false, nil) + require.NoError(t, err) + require.Equal(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, got) +} + func TestPromptDeployMode_FlagOverride(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 2d865a57ee1..7d5bf3a5bdf 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -316,7 +316,7 @@ func TestSynthesizeImageManifestFile(t *testing.T) { const image = "myacr.azurecr.io/agents/my-agent@sha256:" + "76a9463463acf11d4068e8468fb232a3de0709177b6b35de95de6a34b33fa686" - manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image) + manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image, nil) require.NoError(t, err) require.NotNil(t, cleanup) require.FileExists(t, manifestPath) @@ -343,6 +343,65 @@ func TestSynthesizeImageManifestFile(t *testing.T) { require.NoFileExists(t, manifestPath) } +func TestSynthesizeImageManifestFile_UsesFlagProtocols(t *testing.T) { + t.Parallel() + + const agentName = "my-agent" + const image = "myacr.azurecr.io/agents/my-agent:v1" + + manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image, []string{"invocations_ws"}) + require.NoError(t, err) + defer cleanup() + + content, err := os.ReadFile(manifestPath) + require.NoError(t, err) + template, err := agent_yaml.ExtractAgentDefinition(content) + require.NoError(t, err) + + containerAgent, ok := template.(agent_yaml.ContainerAgent) + require.True(t, ok, "synthesized template should be a ContainerAgent, got %T", template) + require.Len(t, containerAgent.Protocols, 1) + require.Equal(t, "invocations_ws", containerAgent.Protocols[0].Protocol) + require.Equal(t, "2.0.0", containerAgent.Protocols[0].Version) +} + +func TestSynthesizeImageManifestFile_RejectsUnknownProtocol(t *testing.T) { + t.Parallel() + + manifestPath, cleanup, err := synthesizeImageManifestFile( + "my-agent", + "myacr.azurecr.io/agents/my-agent:v1", + []string{"unknown"}, + ) + require.Error(t, err) + require.Empty(t, manifestPath) + cleanup() + require.Contains(t, err.Error(), "unknown protocol") +} + +func TestSynthesizeImageManifestFile_AcceptsActivityProtocol(t *testing.T) { + t.Parallel() + + manifestPath, cleanup, err := synthesizeImageManifestFile( + "my-agent", + "myacr.azurecr.io/agents/my-agent:v1", + []string{"activity"}, + ) + require.NoError(t, err) + defer cleanup() + + content, err := os.ReadFile(manifestPath) + require.NoError(t, err) + template, err := agent_yaml.ExtractAgentDefinition(content) + require.NoError(t, err) + + containerAgent, ok := template.(agent_yaml.ContainerAgent) + require.True(t, ok, "synthesized template should be a ContainerAgent, got %T", template) + require.Equal(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity", Version: "2.0.0"}, + }, containerAgent.Protocols) +} + func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { const image = "myacr.azurecr.io/agents/my-agent:v1" server := &recordingProjectServer{} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index 4cb658aaea9..361bdcb9c6a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -17,6 +17,13 @@ const ( // ProtocolResponses is the value of `agent.yaml#protocol` for plain // text /responses agents. ProtocolResponses = "responses" + // ProtocolInvocationsWS is the value of `agent.yaml#protocol` for + // bidirectional WebSocket /invocations_ws agents. + ProtocolInvocationsWS = "invocations_ws" + // ProtocolActivity is the value of `agent.yaml#protocol` for Activity Protocol agents. + ProtocolActivity = "activity" + // ProtocolActivityLegacy is the legacy value accepted for Activity Protocol agents. + ProtocolActivityLegacy = "activity_protocol" // placeholderPayload is the single-quoted literal the resolver // emits as the body argument when no concrete payload is known — @@ -330,6 +337,9 @@ func ResolveAfterRun(state *State, serviceName string, readmeExists func(relativ } svc := findService(state, serviceName) + if !serviceSupportsAzdInvoke(svc) { + return nil + } cachedPayload := "" if state.HasOpenAPI && state.OpenAPIPayload != "" { @@ -343,7 +353,7 @@ func ResolveAfterRun(state *State, serviceName string, readmeExists func(relativ out = append(out, *readmeHint) } out = append(out, Suggestion{ - Command: fmt.Sprintf("azd ai agent invoke --local %s", invokeArg), + Command: fmt.Sprintf("azd ai agent invoke --local %s%s", invokeProtocolFlag(svc), invokeArg), Description: "send a sample request to the running agent", Priority: 10, }) @@ -641,6 +651,9 @@ func ResolveAfterDeploy( // after shows matches the spec example output (lines 238-241). for i := range state.Services { svc := &state.Services[i] + if !serviceSupportsAzdInvoke(svc) { + continue + } cached := "" if cachedPayload != nil { @@ -665,7 +678,7 @@ func ResolveAfterDeploy( } out = append(out, Suggestion{ - Command: fmt.Sprintf("azd ai agent invoke %s %s", svc.Name, invokeArg), + Command: fmt.Sprintf("azd ai agent invoke %s %s%s", svc.Name, invokeProtocolFlag(svc), invokeArg), Description: desc, Priority: priority, }) @@ -675,6 +688,19 @@ func ResolveAfterDeploy( return out } +func serviceSupportsAzdInvoke(svc *ServiceState) bool { + return svc == nil || (svc.Protocol != ProtocolInvocationsWS && + svc.Protocol != ProtocolActivity && + svc.Protocol != ProtocolActivityLegacy) +} + +func invokeProtocolFlag(svc *ServiceState) string { + if svc == nil || !svc.MultiProtocol || svc.Protocol == "" { + return "" + } + return fmt.Sprintf("--protocol %s ", svc.Protocol) +} + func readmeCommand(relativePath string) string { rel := path.Clean(strings.ReplaceAll(relativePath, "\\", "/")) if rel == "" || rel == "." { @@ -770,13 +796,16 @@ func appendInvokeLocalSecondary( if len(state.Services) == 1 { svc = &state.Services[0] } + if !serviceSupportsAzdInvoke(svc) { + return out, priority + } invokeArg, readmeHint := resolveInvokeArg(svc, "", readmeExists, priority) if readmeHint != nil { out = append(out, *readmeHint) priority++ } out = append(out, Suggestion{ - Command: fmt.Sprintf("azd ai agent invoke --local %s", invokeArg), + Command: fmt.Sprintf("azd ai agent invoke --local %s%s", invokeProtocolFlag(svc), invokeArg), Description: "test it in another terminal", Priority: priority, }) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index 231a60eb8cd..0eb89375e65 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -333,6 +333,20 @@ func TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary(t *testing.T assert.Equal(t, `azd ai agent invoke --local ''`, out[1].Command) }) + t.Run("single-agent multi-protocol responses adds explicit protocol flag", func(t *testing.T) { + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + Services: []ServiceState{{ + Name: "echo", Protocol: ProtocolResponses, MultiProtocol: true, + }}, + } + out := ResolveAfterInit(state, nil) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent run", out[0].Command) + assert.Equal(t, `azd ai agent invoke --local --protocol responses ''`, out[1].Command) + }) + t.Run("single-agent invocations protocol → invoke uses placeholder", func(t *testing.T) { t.Parallel() state := &State{ @@ -345,6 +359,27 @@ func TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary(t *testing.T assert.Equal(t, `azd ai agent invoke --local ''`, out[1].Command) }) + for _, tt := range []struct { + name string + protocol string + }{ + {name: "invocations_ws", protocol: ProtocolInvocationsWS}, + {name: "activity", protocol: ProtocolActivity}, + {name: "activity_protocol", protocol: ProtocolActivityLegacy}, + } { + t.Run("single-agent "+tt.name+" protocol suppresses invoke hint", func(t *testing.T) { + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + Services: []ServiceState{{Name: "echo", Protocol: tt.protocol}}, + } + out := ResolveAfterInit(state, nil) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent run", out[0].Command) + assert.Equal(t, "azd deploy", out[1].Command) + }) + } + t.Run("multi-agent → invoke stays unqualified, uses placeholder payload", func(t *testing.T) { t.Parallel() state := &State{ @@ -770,6 +805,41 @@ func TestResolveAfterRun(t *testing.T) { `curl http://localhost:/invocations/docs/openapi.json`, }, }, + { + name: "multi-protocol responses service adds explicit protocol flag", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolResponses, MultiProtocol: true}}, + }, + serviceName: "echo", + want: []string{ + `azd ai agent invoke --local --protocol responses ''`, + `curl http://localhost:/invocations/docs/openapi.json`, + }, + }, + { + name: "invocations_ws protocol suppresses invoke suggestions", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocationsWS}}, + }, + serviceName: "echo", + want: nil, + }, + { + name: "activity protocol suppresses invoke suggestions", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivity}}, + }, + serviceName: "echo", + want: nil, + }, + { + name: "legacy activity protocol suppresses invoke suggestions", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivityLegacy}}, + }, + serviceName: "echo", + want: nil, + }, { name: "unknown protocol, no README → placeholder + tip", state: &State{ @@ -998,6 +1068,41 @@ func TestResolveAfterDeploy(t *testing.T) { assert.Equal(t, "test the deployment", out[1].Description) }) + t.Run("single websocket agent suppresses deploy invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocationsWS}}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + }) + + t.Run("multi-protocol responses service adds deploy protocol flag", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{ + Name: "echo", Protocol: ProtocolResponses, MultiProtocol: true, + }}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, `azd ai agent invoke echo --protocol responses ''`, out[1].Command) + }) + + t.Run("single activity agent suppresses deploy invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivity}}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + }) + + t.Run("single legacy activity agent suppresses deploy invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivityLegacy}}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + }) + t.Run("single agent, no cached payload, README on disk → README then placeholder invoke", func(t *testing.T) { t.Parallel() state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolResponses}}} @@ -1061,6 +1166,19 @@ func TestResolveAfterDeploy(t *testing.T) { assert.Equal(t, "test beta", out[3].Description) }) + t.Run("multi-agent skips websocket invoke but keeps invocable services", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "alpha", Protocol: ProtocolInvocationsWS}, + {Name: "beta", Protocol: ProtocolResponses}, + }} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent show alpha", out[0].Command) + assert.Equal(t, "azd ai agent show beta", out[1].Command) + assert.Equal(t, `azd ai agent invoke beta ''`, out[2].Command) + }) + t.Run("multi-agent README hint placement → before the corresponding placeholder invoke", func(t *testing.T) { t.Parallel() state := &State{Services: []ServiceState{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index bf8bc5078c0..0f2739ff1a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -20,6 +20,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "go.yaml.in/yaml/v3" + "google.golang.org/protobuf/types/known/structpb" ) const ( @@ -438,12 +439,14 @@ func collectServices( if svc == nil || svc.Host != agentHost { continue } + protocol, multiProtocol := loadServiceProtocolInfo(project.Path, svc) services = append(services, ServiceState{ - Name: svc.Name, - Host: svc.Host, - RelativePath: svc.RelativePath, - Protocol: loadServiceProtocol(project.Path, svc.RelativePath), - IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs), + Name: svc.Name, + Host: svc.Host, + RelativePath: svc.RelativePath, + Protocol: protocol, + MultiProtocol: multiProtocol, + IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs), }) } @@ -460,36 +463,103 @@ func collectServices( // manifest declares multiple protocols, ProtocolResponses wins over // ProtocolInvocations so the suggested payload works on the broadest set of // agents. -func loadServiceProtocol(projectPath, relativePath string) string { +func loadServiceProtocol(projectPath string, svc *azdext.ServiceConfig) string { + protocol, _ := loadServiceProtocolInfo(projectPath, svc) + return protocol +} + +func loadServiceProtocolInfo(projectPath string, svc *azdext.ServiceConfig) (string, bool) { + if protocol, multiProtocol := loadServiceProtocolFromConfig(svc); protocol != "" { + return protocol, multiProtocol + } + if svc == nil { + return "", false + } + return loadServiceProtocolFromFile(projectPath, svc.RelativePath) +} + +func loadServiceProtocolFromConfig(svc *azdext.ServiceConfig) (string, bool) { + props := nextStepServiceConfigProps(svc) + if len(props) == 0 { + return "", false + } + data, err := yaml.Marshal(props) + if err != nil { + return "", false + } + return loadServiceProtocolFromBytes(data) +} + +func nextStepServiceConfigProps(svc *azdext.ServiceConfig) map[string]any { + if svc == nil { + return nil + } + inline := svc.GetAdditionalProperties() + if structHasKind(inline) { + return inline.AsMap() + } + cfg := svc.GetConfig() + if structHasKind(cfg) { + return cfg.AsMap() + } + return nil +} + +func structHasKind(s *structpb.Struct) bool { + if s == nil { + return false + } + v, ok := s.GetFields()["kind"] + return ok && strings.TrimSpace(v.GetStringValue()) != "" +} + +func loadServiceProtocolFromFile(projectPath, relativePath string) (string, bool) { if projectPath == "" { - return "" + return "", false } manifestPath, err := paths.JoinAllowRoot(projectPath, relativePath, "agent.yaml") if err != nil { - return "" + return "", false } data, err := os.ReadFile(manifestPath) //nolint:gosec // path is validated under the project root if err != nil { - return "" + return "", false } + return loadServiceProtocolFromBytes(data) +} + +func loadServiceProtocolFromBytes(data []byte) (string, bool) { var hosted agent_yaml.ContainerAgent if err := yaml.Unmarshal(data, &hosted); err != nil { - return "" + return "", false } + multiProtocol := len(hosted.Protocols) > 1 sawInvocations := false + sawActivity := false + sawInvocationsWS := false for _, p := range hosted.Protocols { switch strings.TrimSpace(p.Protocol) { case ProtocolResponses: - return ProtocolResponses + return ProtocolResponses, multiProtocol + case ProtocolInvocationsWS: + sawInvocationsWS = true case ProtocolInvocations: sawInvocations = true + case ProtocolActivity, ProtocolActivityLegacy: + sawActivity = true } } if sawInvocations { - return ProtocolInvocations + return ProtocolInvocations, multiProtocol + } + if sawActivity { + return ProtocolActivity, multiProtocol + } + if sawInvocationsWS { + return ProtocolInvocationsWS, multiProtocol } - return "" + return "", multiProtocol } // detectMissingVars walks each service's agent.yaml environment_variables diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 4663ad937f2..e118f83a8bd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -13,6 +13,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // fakeSource is a hand-rolled Source for table-driven tests. @@ -596,6 +597,33 @@ protocols: `, want: ProtocolInvocations, }, + { + name: "single invocations_ws protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations_ws + version: "2.0.0" +`, + want: ProtocolInvocationsWS, + }, + { + name: "single activity protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: activity + version: "2.0.0" +`, + want: ProtocolActivity, + }, + { + name: "single legacy activity protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: activity_protocol + version: "1.0.0" +`, + want: ProtocolActivity, + }, { name: "responses wins when both declared", manifest: `kind: hostedAgent @@ -607,6 +635,39 @@ protocols: `, want: ProtocolResponses, }, + { + name: "responses wins over invocations_ws regardless of order", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations_ws + version: "2.0.0" + - protocol: responses + version: "2.0.0" +`, + want: ProtocolResponses, + }, + { + name: "invocations wins over invocations_ws regardless of order", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations_ws + version: "2.0.0" + - protocol: invocations + version: "1.0.0" +`, + want: ProtocolInvocations, + }, + { + name: "invocations wins over activity regardless of order", + manifest: `kind: hostedAgent +protocols: + - protocol: activity + version: "2.0.0" + - protocol: invocations + version: "1.0.0" +`, + want: ProtocolInvocations, + }, { name: "empty protocols section", manifest: `kind: hostedAgent @@ -651,7 +712,7 @@ protocols: 0o600, )) } - got := loadServiceProtocol(projectRoot, relPath) + got := loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: relPath}) assert.Equal(t, tt.want, got) }) } @@ -660,7 +721,7 @@ protocols: func TestLoadServiceProtocol_EmptyArgs(t *testing.T) { t.Parallel() - assert.Equal(t, "", loadServiceProtocol("", "echo")) + assert.Equal(t, "", loadServiceProtocol("", &azdext.ServiceConfig{RelativePath: "echo"})) } func TestLoadServiceProtocol_RootRelativePath(t *testing.T) { @@ -673,8 +734,8 @@ func TestLoadServiceProtocol_RootRelativePath(t *testing.T) { 0o600, )) - assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, "")) - assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, ".")) + assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, &azdext.ServiceConfig{})) + assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: "."})) } func TestLoadServiceProtocol_RejectsTraversal(t *testing.T) { @@ -691,7 +752,50 @@ func TestLoadServiceProtocol_RejectsTraversal(t *testing.T) { 0o600, )) - assert.Equal(t, "", loadServiceProtocol(projectRoot, "../outside")) + assert.Equal(t, "", loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: "../outside"})) +} + +func TestLoadServiceProtocol_InlineAdditionalPropertiesWinOverAgentYaml(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + relPath := "echo" + svcDir := filepath.Join(projectRoot, relPath) + require.NoError(t, os.MkdirAll(svcDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(svcDir, "agent.yaml"), + []byte("kind: hostedAgent\nprotocols:\n - protocol: responses\n version: \"2.0.0\"\n"), + 0o600, + )) + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + got := loadServiceProtocol(projectRoot, &azdext.ServiceConfig{ + RelativePath: relPath, + AdditionalProperties: config, + }) + assert.Equal(t, ProtocolInvocationsWS, got) +} + +func TestLoadServiceProtocol_LegacyConfigFallback(t *testing.T) { + t.Parallel() + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + got := loadServiceProtocol(t.TempDir(), &azdext.ServiceConfig{Config: config}) + assert.Equal(t, ProtocolInvocationsWS, got) } func TestAssembleState_PopulatesProtocolFromAgentYaml(t *testing.T) { @@ -721,6 +825,62 @@ func TestAssembleState_PopulatesProtocolFromAgentYaml(t *testing.T) { assert.Equal(t, ProtocolInvocations, state.Services[0].Protocol) } +func TestAssembleState_PopulatesProtocolFromInlineAdditionalProperties(t *testing.T) { + t.Parallel() + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: t.TempDir(), + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, AdditionalProperties: config}, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.Len(t, state.Services, 1) + assert.Equal(t, ProtocolInvocationsWS, state.Services[0].Protocol) +} + +func TestAssembleState_MarksInlineMultiProtocolService(t *testing.T) { + t.Parallel() + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + map[string]any{"protocol": "responses", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: t.TempDir(), + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, AdditionalProperties: config}, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.Len(t, state.Services, 1) + assert.Equal(t, ProtocolResponses, state.Services[0].Protocol) + assert.True(t, state.Services[0].MultiProtocol) +} + func TestExtractAgentYamlEnvRefs(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go index 52c91382201..0eac6c2d0dc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go @@ -210,9 +210,10 @@ type ResourceRef struct { // underscores — the convention used by the deploy-time env-var writer in // project/service_target_agent.go. type ServiceState struct { - Name string - Host string - Protocol string - RelativePath string - IsDeployed bool + Name string + Host string + Protocol string + MultiProtocol bool + RelativePath string + IsDeployed bool } 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 c9b35d0eceb..124b2d4ad91 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 @@ -4,6 +4,7 @@ package agent_yaml import ( + "encoding/json" "math" "strings" "testing" @@ -1604,6 +1605,55 @@ func TestCreateHostedAgentAPIRequest_WithRaiConfig(t *testing.T) { } } +func TestCreateAgentAPIRequest_CodeDeploy_InvocationsWsUsesProtocolVersions(t *testing.T) { + depRes := "remote_build" + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Name: "voice-agent", + Kind: AgentKindHosted, + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + CodeConfiguration: &CodeConfiguration{ + Runtime: "python_3_13", + EntryPoint: "main.py", + DependencyResolution: &depRes, + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + codeDef, ok := req.Definition.(agent_api.HostedAgentDefinition) + if !ok { + t.Fatalf("expected HostedAgentDefinition, got %T", req.Definition) + } + if len(codeDef.ProtocolVersions) != 1 { + t.Fatalf("got %d protocols, want 1", len(codeDef.ProtocolVersions)) + } + if codeDef.ProtocolVersions[0].Protocol != agent_api.AgentProtocolInvocationsWS { + t.Errorf("protocol = %q, want %q", codeDef.ProtocolVersions[0].Protocol, agent_api.AgentProtocolInvocationsWS) + } + if codeDef.ProtocolVersions[0].Version != "2.0.0" { + t.Errorf("version = %q, want %q", codeDef.ProtocolVersions[0].Version, "2.0.0") + } + + data, err := json.Marshal(codeDef) + if err != nil { + t.Fatalf("marshal: %v", err) + } + jsonText := string(data) + if !strings.Contains(jsonText, `"protocol_versions"`) { + t.Fatalf("serialized definition = %s, want protocol_versions", jsonText) + } + if strings.Contains(jsonText, `"container_protocol_versions"`) { + t.Fatalf("serialized definition = %s, did not expect container_protocol_versions", jsonText) + } +} + func TestCreateAgentAPIRequest_CodeDeploy_WithRaiConfig(t *testing.T) { t.Parallel() const raiPolicyID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index a6ac2d23f0d..1e362c72ed7 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -72,7 +72,7 @@ }, "protocols": { "type": "array", - "description": "Invocation protocols the agent implements (e.g., responses, invocations, a2a).", + "description": "Invocation protocols the agent implements (e.g., responses, invocations, invocations_ws, a2a).", "items": { "$ref": "#/definitions/ProtocolVersionRecord" } }, "agentEndpoint": { @@ -110,7 +110,7 @@ "type": "object", "description": "A protocol the agent implements, with its version.", "properties": { - "protocol": { "type": "string", "description": "Protocol name (e.g., 'responses', 'invocations', 'a2a')." }, + "protocol": { "type": "string", "description": "Protocol name (e.g., 'responses', 'invocations', 'invocations_ws', 'a2a')." }, "version": { "type": "string", "description": "Protocol version." } }, "required": ["protocol"],