From d0fa0b58dc431bdff09ff9b198fa8249d2638794 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 15:49:23 +0800 Subject: [PATCH 01/17] feat(ai.agents): native Azure Bot + Teams channel for activity-protocol agents Provision the Azure Bot and Microsoft Teams channel for activity-protocol (Teams) hosted agents natively during `azd deploy`, replacing the setup-instance-bot.ps1 postdeploy script. The bot is bound to the agent instance identity (msaAppId = instance identity client id), enabled on the Teams channel, and its messaging endpoint is pointed at the agent's activity protocol endpoint. Teardown deletes the bot so its globally unique name is freed for redeploys. Non-activity agents are completely unaffected (single gate on the activity profile). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/go.mod | 1 + cli/azd/extensions/azure.ai.agents/go.sum | 2 + .../azure.ai.agents/internal/cmd/listen.go | 13 ++ .../internal/cmd/listen_activity.go | 200 +++++++++++++++++ .../internal/pkg/botservice/botservice.go | 205 ++++++++++++++++++ .../pkg/botservice/botservice_test.go | 158 ++++++++++++++ .../internal/project/activity_profile.go | 66 ++++++ .../internal/project/activity_profile_test.go | 154 +++++++++++++ 8 files changed, 799 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index dc8e461f274..7723add84c6 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -36,6 +36,7 @@ require ( require github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817 require ( + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/botservice/armbotservice v1.2.0 github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 github.com/creack/pty v1.1.24 github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index 3a4cc258c9e..cbe8b2fbaaa 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -17,6 +17,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthoriza github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 h1:qiir/pptnHqp6hV8QwV+IExYIf6cPsXBfUDUXQ27t2Y= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/botservice/armbotservice v1.2.0 h1:RsM+VqnIUK6WLmKjIA0tbcZHdjoq9sVKaPTT4RrlnWs= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/botservice/armbotservice v1.2.0/go.mod h1:d7OLd8MIV32CmujSnOFkT7R5N0YsV5qVI+WnKHzQujQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 h1:pxphC/uRZKNHNPbZ0duDDgKkefju2F03OkG5xF6byHQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0/go.mod h1:twcwRey+l1znKBL5TEzYiZMtiVkWfM7Pq8a9vY04xYc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.3.0-beta.3 h1:4qfc7os3wRQcl+ImfeH9z0abWJzuV9IGcN1B9olmPTU= diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 29f70766347..68bba80a4a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -380,6 +380,15 @@ func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *a return fmt.Errorf("agent identity RBAC setup failed: %w", err) } + // For activity-protocol (Teams) agents, provision the Azure Bot + Teams + // channel bound to the agent instance identity. No-op for other agents. + if err := ensureActivityBot( + ctx, azdClient, cred, envName, svc, args.Project, + endpointResp.Value, tenantResp.Value, versionObj, + ); err != nil { + return fmt.Errorf("failed to configure Teams bot for agent %q: %w", svc.Name, err) + } + // Report optimization candidate deployment (best-effort: panics are logged, not propagated). func() { defer func() { @@ -418,6 +427,10 @@ func postdownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azd } } + // Delete the Azure Bot created for activity-protocol agents so its globally + // unique name is freed for future redeploys. Best-effort. + teardownActivityBots(ctx, azdClient, envName, args.Project) + return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go new file mode 100644 index 00000000000..c7258894bad --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/botservice" + "azureaiagent/internal/project" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// ensureActivityBot provisions the Azure Bot + Microsoft Teams channel for an +// activity-protocol (Teams) agent during postdeploy. It is a no-op for any agent +// that does not opt into the Activity protocol, so non-activity deployments are +// completely unaffected. +// +// Scope: azd owns the Azure resource plane only — create the bot, bind it to the +// agent instance identity, enable the Teams channel, and point its messaging +// endpoint at the agent. Teams app packaging and install live on the M365/Graph +// plane and stay out of azd; postdeploy prints a guide for those manual steps. +func ensureActivityBot( + ctx context.Context, + azdClient *azdext.AzdClient, + cred azcore.TokenCredential, + envName string, + svc *azdext.ServiceConfig, + proj *azdext.ProjectConfig, + projectEndpoint string, + tenantID string, + versionObj *agent_api.AgentVersionObject, +) error { + ca, isHosted, _, err := project.LoadAgentDefinition(svc, proj.Path) + if err != nil || !isHosted { + return nil + } + + profile := project.ResolveActivityProfile(ca) + if !profile.IsActivity { + return nil + } + + // Phase 1 supports the simple use case only: the bot msaAppId is the agent + // instance identity client id, which only exists after the agent version is + // created during deploy. + if versionObj == nil || versionObj.InstanceIdentity == nil || + versionObj.InstanceIdentity.ClientID == "" { + return fmt.Errorf( + "activity agent %q has no instance identity client id yet; cannot bind the "+ + "Teams bot. Re-run 'azd deploy' once the agent version is active.", + svc.Name, + ) + } + msaAppID := versionObj.InstanceIdentity.ClientID + + subscriptionID, err := readEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID") + if err != nil { + return err + } + resourceGroup, err := readEnvValue(ctx, azdClient, envName, "AZURE_RESOURCE_GROUP") + if err != nil { + return err + } + + botClient, err := botservice.NewClient(subscriptionID, cred, nil) + if err != nil { + return err + } + + // The API agent name is the service name (deploy fetched the version with it), + // so the messaging endpoint and bot name must use the same value. + agentName := svc.Name + botName := botservice.BotName(agentName) + + cfg := botservice.BotConfig{ + ResourceGroup: resourceGroup, + BotName: botName, + MsaAppID: msaAppID, + TenantID: tenantID, + MessagingEndpoint: botservice.MessagingEndpoint(projectEndpoint, agentName), + DisplayName: agentName, + } + + fmt.Printf("Configuring Azure Bot %q for Teams (activity protocol)...\n", botName) + if err := botClient.EnsureBot(ctx, cfg); err != nil { + return err + } + + printTeamsNextSteps(agentName, botName) + return nil +} + +// printTeamsNextSteps prints the manual, out-of-azd steps needed to make the +// Teams bot reachable from a Teams client: package the Teams app (botId = the +// bot's msaAppId) and sideload/install it. These live on the M365 plane. +func printTeamsNextSteps(agentName, botName string) { + fmt.Println(output.WithHighLightFormat("\nTeams bot ready.")) + fmt.Printf(" Azure Bot: %s (Microsoft Teams channel enabled)\n", botName) + fmt.Println("\nNext steps (outside azd — Teams app packaging & install):") + fmt.Println(output.WithGrayFormat( + " 1. Package the Teams app with botId = the bot's msaAppId " + + "(see scripts/package-teams-app.ps1 or the Microsoft 365 Agents Toolkit).", + )) + fmt.Println(output.WithGrayFormat( + " 2. Sideload it: Teams -> Apps -> Manage your apps -> Upload a custom app.", + )) +} + +// readEnvValue reads a required environment value, returning a descriptive error +// when it is missing or empty. +func readEnvValue( + ctx context.Context, azdClient *azdext.AzdClient, envName, key string, +) (string, error) { + resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: key, + }) + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", key, err) + } + if resp.Value == "" { + return "", fmt.Errorf("%s is not set in the environment", key) + } + return resp.Value, nil +} + +// teardownActivityBots deletes the Azure Bot created for each activity-protocol +// agent during teardown. BotService resource names are globally unique, so an +// orphaned bot would collide with a future redeploy. It is best-effort: missing +// environment values or delete failures are logged and never block azd down. +func teardownActivityBots( + ctx context.Context, azdClient *azdext.AzdClient, envName string, proj *azdext.ProjectConfig, +) { + var activityAgents []string + for _, svc := range proj.Services { + if svc.Host != AiAgentHost { + continue + } + ca, isHosted, _, err := project.LoadAgentDefinition(svc, proj.Path) + if err != nil || !isHosted { + continue + } + if project.IsActivityProtocol(ca) { + activityAgents = append(activityAgents, svc.Name) + } + } + if len(activityAgents) == 0 { + return + } + + subscriptionID, err := readEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID") + if err != nil { + log.Printf("postdown: skipping Teams bot cleanup: %v", err) + return + } + resourceGroup, err := readEnvValue(ctx, azdClient, envName, "AZURE_RESOURCE_GROUP") + if err != nil { + log.Printf("postdown: skipping Teams bot cleanup: %v", err) + return + } + tenantID, err := readEnvValue(ctx, azdClient, envName, "AZURE_TENANT_ID") + if err != nil { + log.Printf("postdown: skipping Teams bot cleanup: %v", err) + return + } + + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: tenantID, + AdditionallyAllowedTenants: []string{"*"}, + }, + ) + if err != nil { + log.Printf("postdown: skipping Teams bot cleanup: %v", err) + return + } + + botClient, err := botservice.NewClient(subscriptionID, cred, nil) + if err != nil { + log.Printf("postdown: skipping Teams bot cleanup: %v", err) + return + } + + for _, agentName := range activityAgents { + botName := botservice.BotName(agentName) + if err := botClient.DeleteBot(ctx, resourceGroup, botName); err != nil { + log.Printf("postdown: failed to delete Azure Bot %q: %v", botName, err) + continue + } + fmt.Printf("Deleted Azure Bot %q for agent %q\n", botName, agentName) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go new file mode 100644 index 00000000000..9abf37b8f82 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package botservice provisions and tears down the Azure Bot and Microsoft Teams +// channel that front an activity-protocol (Teams) agent. It ports the Azure +// resource-plane steps of setup-instance-bot.ps1 (create bot, enable Teams +// channel, set messaging endpoint) into the native azd deploy flow. Teams app +// packaging and install stay out of scope — they live on the M365/Graph plane. +package botservice + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/botservice/armbotservice" +) + +const ( + // botLocation is the ARM location for Azure Bot resources. Bots are global + // and must be created at "global". + botLocation = "global" + // teamsChannelName is the discriminator value the Teams channel resource + // carries in its ChannelName field. + teamsChannelName = "MsTeamsChannel" + // messagingEndpointAPIVersion is the api-version the activity-protocol + // messaging endpoint URL is pinned to (verified against the POC). + messagingEndpointAPIVersion = "2025-05-15-preview" + // botNameSuffix is appended to the agent name to form the bot resource name. + // BotService names are globally unique, so teardown must delete the bot. + botNameSuffix = "-bot-uai" +) + +// botsAPI and channelsAPI are the narrow slices of the armbotservice clients this +// package uses, extracted as interfaces so tests can substitute fakes. +type botsAPI interface { + Create( + ctx context.Context, resourceGroupName, resourceName string, + parameters armbotservice.Bot, options *armbotservice.BotsClientCreateOptions, + ) (armbotservice.BotsClientCreateResponse, error) + Delete( + ctx context.Context, resourceGroupName, resourceName string, + options *armbotservice.BotsClientDeleteOptions, + ) (armbotservice.BotsClientDeleteResponse, error) +} + +type channelsAPI interface { + Create( + ctx context.Context, resourceGroupName, resourceName string, + channelName armbotservice.ChannelName, parameters armbotservice.BotChannel, + options *armbotservice.ChannelsClientCreateOptions, + ) (armbotservice.ChannelsClientCreateResponse, error) +} + +// Client provisions and tears down the Azure Bot + Microsoft Teams channel for an +// activity-protocol agent. Its operations are idempotent and safe to run after +// every deploy. +type Client struct { + bots botsAPI + channels channelsAPI +} + +// NewClient builds a Client backed by the armbotservice SDK for a subscription. +func NewClient( + subscriptionID string, cred azcore.TokenCredential, opts *arm.ClientOptions, +) (*Client, error) { + bots, err := armbotservice.NewBotsClient(subscriptionID, cred, opts) + if err != nil { + return nil, fmt.Errorf("botservice: creating bots client: %w", err) + } + channels, err := armbotservice.NewChannelsClient(subscriptionID, cred, opts) + if err != nil { + return nil, fmt.Errorf("botservice: creating channels client: %w", err) + } + return &Client{bots: bots, channels: channels}, nil +} + +// BotName returns the conventional Azure Bot resource name for an agent. It +// matches the name used by the sample's setup-instance-bot.ps1 so an existing +// bot is reused rather than duplicated. +func BotName(agentName string) string { + return agentName + botNameSuffix +} + +// MessagingEndpoint returns the activity-protocol messaging endpoint URL the bot +// forwards inbound Teams activities to. +func MessagingEndpoint(projectEndpoint, agentName string) string { + return fmt.Sprintf( + "%s/agents/%s/endpoint/protocols/activityProtocol?api-version=%s", + strings.TrimRight(projectEndpoint, "/"), agentName, messagingEndpointAPIVersion, + ) +} + +// BotConfig describes the Azure Bot to ensure for an activity-protocol agent. +type BotConfig struct { + ResourceGroup string + BotName string + // MsaAppID is the single-tenant app id the bot authenticates as. For the + // simple use case this is the agent instance identity client id. + MsaAppID string + TenantID string + MessagingEndpoint string + // DisplayName is optional; BotName is used when empty. + DisplayName string +} + +func (cfg BotConfig) validate() error { + var missing []string + if cfg.ResourceGroup == "" { + missing = append(missing, "ResourceGroup") + } + if cfg.BotName == "" { + missing = append(missing, "BotName") + } + if cfg.MsaAppID == "" { + missing = append(missing, "MsaAppID") + } + if cfg.TenantID == "" { + missing = append(missing, "TenantID") + } + if cfg.MessagingEndpoint == "" { + missing = append(missing, "MessagingEndpoint") + } + if len(missing) > 0 { + return fmt.Errorf("botservice: missing required bot config: %s", strings.Join(missing, ", ")) + } + return nil +} + +func (cfg BotConfig) displayName() string { + if cfg.DisplayName != "" { + return cfg.DisplayName + } + return cfg.BotName +} + +// EnsureBot idempotently creates (or updates) the single-tenant Azure Bot bound +// to MsaAppID and ensures the Microsoft Teams channel is enabled. The bot Create +// call is a PUT (create-or-update), so re-running after every deploy is a no-op +// when nothing changed and refreshes the messaging endpoint when it did. +func (c *Client) EnsureBot(ctx context.Context, cfg BotConfig) error { + if err := cfg.validate(); err != nil { + return err + } + + bot := armbotservice.Bot{ + Location: to.Ptr(botLocation), + Kind: to.Ptr(armbotservice.KindAzurebot), + SKU: &armbotservice.SKU{Name: to.Ptr(armbotservice.SKUNameF0)}, + Properties: &armbotservice.BotProperties{ + DisplayName: to.Ptr(cfg.displayName()), + Endpoint: to.Ptr(cfg.MessagingEndpoint), + MsaAppID: to.Ptr(cfg.MsaAppID), + MsaAppType: to.Ptr(armbotservice.MsaAppTypeSingleTenant), + MsaAppTenantID: to.Ptr(cfg.TenantID), + }, + } + + if _, err := c.bots.Create(ctx, cfg.ResourceGroup, cfg.BotName, bot, nil); err != nil { + return fmt.Errorf("botservice: creating/updating bot %q: %w", cfg.BotName, err) + } + + return c.ensureTeamsChannel(ctx, cfg.ResourceGroup, cfg.BotName) +} + +func (c *Client) ensureTeamsChannel(ctx context.Context, resourceGroup, botName string) error { + channel := armbotservice.BotChannel{ + Location: to.Ptr(botLocation), + Properties: &armbotservice.MsTeamsChannel{ + ChannelName: to.Ptr(teamsChannelName), + Properties: &armbotservice.MsTeamsChannelProperties{ + IsEnabled: to.Ptr(true), + }, + }, + } + + if _, err := c.channels.Create( + ctx, resourceGroup, botName, armbotservice.ChannelNameMsTeamsChannel, channel, nil, + ); err != nil { + return fmt.Errorf("botservice: enabling Microsoft Teams channel on bot %q: %w", botName, err) + } + return nil +} + +// DeleteBot removes the Azure Bot during teardown. A not-found response is +// treated as success so teardown is idempotent. +func (c *Client) DeleteBot(ctx context.Context, resourceGroup, botName string) error { + if _, err := c.bots.Delete(ctx, resourceGroup, botName, nil); err != nil { + if isNotFound(err) { + return nil + } + return fmt.Errorf("botservice: deleting bot %q: %w", botName, err) + } + return nil +} + +func isNotFound(err error) bool { + var respErr *azcore.ResponseError + return errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go new file mode 100644 index 00000000000..50e19606ece --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package botservice + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/botservice/armbotservice" +) + +type fakeBots struct { + createCalls []armbotservice.Bot + createErr error + deleteCalls int + deleteErr error +} + +func (f *fakeBots) Create( + _ context.Context, _, _ string, + parameters armbotservice.Bot, _ *armbotservice.BotsClientCreateOptions, +) (armbotservice.BotsClientCreateResponse, error) { + f.createCalls = append(f.createCalls, parameters) + return armbotservice.BotsClientCreateResponse{}, f.createErr +} + +func (f *fakeBots) Delete( + _ context.Context, _, _ string, _ *armbotservice.BotsClientDeleteOptions, +) (armbotservice.BotsClientDeleteResponse, error) { + f.deleteCalls++ + return armbotservice.BotsClientDeleteResponse{}, f.deleteErr +} + +type fakeChannels struct { + createCalls []armbotservice.ChannelName + createErr error +} + +func (f *fakeChannels) Create( + _ context.Context, _, _ string, channelName armbotservice.ChannelName, + _ armbotservice.BotChannel, _ *armbotservice.ChannelsClientCreateOptions, +) (armbotservice.ChannelsClientCreateResponse, error) { + f.createCalls = append(f.createCalls, channelName) + return armbotservice.ChannelsClientCreateResponse{}, f.createErr +} + +func validConfig() BotConfig { + return BotConfig{ + ResourceGroup: "rg1", + BotName: "echo-bot-uai", + MsaAppID: "client-id-123", + TenantID: "tenant-abc", + MessagingEndpoint: "https://proj/agents/echo/endpoint/protocols/activityProtocol?api-version=2025-05-15-preview", + } +} + +func TestEnsureBotCreatesSingleTenantBotAndTeamsChannel(t *testing.T) { + bots := &fakeBots{} + channels := &fakeChannels{} + c := &Client{bots: bots, channels: channels} + + if err := c.EnsureBot(context.Background(), validConfig()); err != nil { + t.Fatalf("EnsureBot returned error: %v", err) + } + + if len(bots.createCalls) != 1 { + t.Fatalf("expected 1 bot create call, got %d", len(bots.createCalls)) + } + got := bots.createCalls[0] + if got.Properties == nil { + t.Fatal("bot properties are nil") + } + if got.Properties.MsaAppType == nil || *got.Properties.MsaAppType != armbotservice.MsaAppTypeSingleTenant { + t.Errorf("MsaAppType = %v, want SingleTenant", got.Properties.MsaAppType) + } + if got.Properties.MsaAppID == nil || *got.Properties.MsaAppID != "client-id-123" { + t.Errorf("MsaAppID = %v, want client-id-123", got.Properties.MsaAppID) + } + if got.Properties.MsaAppTenantID == nil || *got.Properties.MsaAppTenantID != "tenant-abc" { + t.Errorf("MsaAppTenantID = %v, want tenant-abc", got.Properties.MsaAppTenantID) + } + if got.SKU == nil || got.SKU.Name == nil || *got.SKU.Name != armbotservice.SKUNameF0 { + t.Errorf("SKU = %v, want F0", got.SKU) + } + if got.Location == nil || *got.Location != "global" { + t.Errorf("Location = %v, want global", got.Location) + } + + if len(channels.createCalls) != 1 || channels.createCalls[0] != armbotservice.ChannelNameMsTeamsChannel { + t.Errorf("expected MsTeamsChannel create, got %v", channels.createCalls) + } +} + +func TestEnsureBotIsIdempotentAcrossRuns(t *testing.T) { + bots := &fakeBots{} + channels := &fakeChannels{} + c := &Client{bots: bots, channels: channels} + + for i := 0; i < 3; i++ { + if err := c.EnsureBot(context.Background(), validConfig()); err != nil { + t.Fatalf("run %d: EnsureBot error: %v", i, err) + } + } + // Create is a PUT (create-or-update); re-running is safe and re-applies + // the same desired state each time. + if len(bots.createCalls) != 3 || len(channels.createCalls) != 3 { + t.Errorf("expected 3 bot + 3 channel calls, got %d + %d", + len(bots.createCalls), len(channels.createCalls)) + } +} + +func TestEnsureBotValidatesConfig(t *testing.T) { + c := &Client{bots: &fakeBots{}, channels: &fakeChannels{}} + cfg := validConfig() + cfg.MsaAppID = "" + err := c.EnsureBot(context.Background(), cfg) + if err == nil { + t.Fatal("expected validation error for missing MsaAppID") + } +} + +func TestDeleteBotTreatsNotFoundAsSuccess(t *testing.T) { + bots := &fakeBots{deleteErr: &azcore.ResponseError{StatusCode: http.StatusNotFound}} + c := &Client{bots: bots, channels: &fakeChannels{}} + + if err := c.DeleteBot(context.Background(), "rg1", "echo-bot-uai"); err != nil { + t.Fatalf("expected nil error on 404, got %v", err) + } + if bots.deleteCalls != 1 { + t.Errorf("expected 1 delete call, got %d", bots.deleteCalls) + } +} + +func TestDeleteBotPropagatesOtherErrors(t *testing.T) { + bots := &fakeBots{deleteErr: errors.New("boom")} + c := &Client{bots: bots, channels: &fakeChannels{}} + + if err := c.DeleteBot(context.Background(), "rg1", "echo-bot-uai"); err == nil { + t.Fatal("expected error to propagate") + } +} + +func TestBotName(t *testing.T) { + if got := BotName("echo28ju3pm"); got != "echo28ju3pm-bot-uai" { + t.Errorf("BotName = %q, want echo28ju3pm-bot-uai", got) + } +} + +func TestMessagingEndpoint(t *testing.T) { + want := "https://proj.example.com/agents/echo/endpoint/protocols/activityProtocol?api-version=2025-05-15-preview" + if got := MessagingEndpoint("https://proj.example.com/", "echo"); got != want { + t.Errorf("MessagingEndpoint = %q, want %q", got, want) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go new file mode 100644 index 00000000000..181cd977301 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "strings" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// ActivityUseCase identifies the Teams hosting/auth model an activity-protocol +// agent targets. Phase 1 supports only the "simple" model (an Azure Bot bound to +// the agent instance identity). The digital-worker model is a Phase 2 addition. +type ActivityUseCase string + +const ( + // ActivityUseCaseSimple is the default single-tenant Teams bot model whose + // msaAppId is the agent instance identity client id. + ActivityUseCaseSimple ActivityUseCase = "simple" + // ActivityUseCaseDigitalWorker is the blueprint + federated-identity model + // (Phase 2). Not yet resolved by ResolveActivityProfile. + ActivityUseCaseDigitalWorker ActivityUseCase = "digital_worker" +) + +// ActivityProfile summarizes the activity-protocol characteristics of a hosted +// agent definition. It is the single gate that keeps all Teams/bot-specific +// behaviour off the path of non-activity agents: when IsActivity is false the +// native provision/deploy flow is completely unchanged. +type ActivityProfile struct { + // IsActivity reports whether the agent opts into the Activity protocol. + IsActivity bool + // UseCase is the resolved Teams hosting model. Only meaningful when + // IsActivity is true. Phase 1 always resolves ActivityUseCaseSimple. + UseCase ActivityUseCase +} + +// IsActivityProtocol reports whether a hosted agent definition opts into the +// Activity protocol, either through a container-level activity_protocol entry or +// an agent_endpoint that advertises the friendly "activity" protocol. +func IsActivityProtocol(ca agent_yaml.ContainerAgent) bool { + for _, p := range ca.Protocols { + if agent_api.AgentProtocol(strings.TrimSpace(p.Protocol)) == agent_api.AgentProtocolActivityProtocol { + return true + } + } + if ca.AgentEndpoint != nil { + for _, p := range ca.AgentEndpoint.Protocols { + if agent_api.AgentEndpointProtocol(strings.TrimSpace(p)) == agent_api.AgentEndpointProtocolActivity { + return true + } + } + } + return false +} + +// ResolveActivityProfile derives the ActivityProfile for a hosted agent +// definition. Phase 1 always resolves the simple use case for activity agents; +// digital-worker detection is a Phase 2 addition. +func ResolveActivityProfile(ca agent_yaml.ContainerAgent) ActivityProfile { + if !IsActivityProtocol(ca) { + return ActivityProfile{} + } + return ActivityProfile{IsActivity: true, UseCase: ActivityUseCaseSimple} +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go new file mode 100644 index 00000000000..5116d59a257 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestIsActivityProtocol(t *testing.T) { + tests := []struct { + name string + ca agent_yaml.ContainerAgent + want bool + }{ + { + name: "container activity_protocol", + ca: agent_yaml.ContainerAgent{ + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity_protocol", Version: "v1"}, + }, + }, + want: true, + }, + { + name: "endpoint activity protocol", + ca: agent_yaml.ContainerAgent{ + AgentEndpoint: &agent_yaml.AgentEndpoint{ + Protocols: []string{"activity"}, + }, + }, + want: true, + }, + { + name: "activity_protocol with surrounding whitespace", + ca: agent_yaml.ContainerAgent{ + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: " activity_protocol ", Version: "v1"}, + }, + }, + want: true, + }, + { + name: "responses protocol only", + ca: agent_yaml.ContainerAgent{ + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + AgentEndpoint: &agent_yaml.AgentEndpoint{ + Protocols: []string{"responses"}, + }, + }, + want: false, + }, + { + name: "empty definition", + ca: agent_yaml.ContainerAgent{}, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsActivityProtocol(tc.ca); got != tc.want { + t.Errorf("IsActivityProtocol() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestResolveActivityProfile(t *testing.T) { + t.Run("activity agent resolves simple", func(t *testing.T) { + ca := agent_yaml.ContainerAgent{ + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity_protocol", Version: "v1"}, + }, + } + got := ResolveActivityProfile(ca) + if !got.IsActivity { + t.Fatalf("expected IsActivity=true") + } + if got.UseCase != ActivityUseCaseSimple { + t.Errorf("UseCase = %q, want %q", got.UseCase, ActivityUseCaseSimple) + } + }) + + t.Run("non-activity agent resolves empty profile", func(t *testing.T) { + ca := agent_yaml.ContainerAgent{ + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + } + got := ResolveActivityProfile(ca) + if got.IsActivity { + t.Fatalf("expected IsActivity=false") + } + if got.UseCase != "" { + t.Errorf("UseCase = %q, want empty", got.UseCase) + } + }) +} + +// TestActivityDeclarationSurvivesInitRoundTrip locks the behaviour that +// `azd ai agent init` preserves the activity-protocol declaration (container +// protocols + agent_endpoint) when it moves an agent definition into azure.yaml +// service properties. Without this, postdeploy's activity gate would never fire +// on an init-generated project. It guards against a future refactor silently +// dropping AgentEndpoint/Protocols from the inline round-trip. +func TestActivityDeclarationSurvivesInitRoundTrip(t *testing.T) { + src := agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "echo28ju3pm", + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity_protocol", Version: "v1"}, + }, + AgentEndpoint: &agent_yaml.AgentEndpoint{ + Protocols: []string{"activity"}, + AuthorizationSchemes: []agent_yaml.AuthorizationScheme{ + {Type: "BotServiceRbac"}, + }, + }, + } + + props, err := AgentDefinitionToServiceProperties(src, nil) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "echo28ju3pm", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + got, isHosted, found, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.True(t, isHosted) + + require.True(t, IsActivityProtocol(got), "activity declaration must survive the round-trip") + require.True(t, ResolveActivityProfile(got).IsActivity) + + require.Len(t, got.Protocols, 1) + require.Equal(t, "activity_protocol", got.Protocols[0].Protocol) + require.NotNil(t, got.AgentEndpoint) + require.Equal(t, []string{"activity"}, got.AgentEndpoint.Protocols) + require.Len(t, got.AgentEndpoint.AuthorizationSchemes, 1) + require.Equal(t, "BotServiceRbac", got.AgentEndpoint.AuthorizationSchemes[0].Type) +} From 4afe28425ac297b2bc7b29f89e3c8c580a1f0b9f Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 16:31:42 +0800 Subject: [PATCH 02/17] feat(ai.agents): support activity_protocol in init-from-code Extend the init-from-local-code path so activity-protocol agents can be initialized without a manifest, producing an azure.yaml identical to the init-from-manifest path and fully satisfying azd deploy. - add activity_protocol/v1 to knownProtocols (opt-in via --protocol or interactive multiselect; no-prompt default unchanged: responses/2.0.0) - inject activity agentEndpoint (protocols: [activity], authorizationSchemes: [BotServiceRbac]) when IsActivityProtocol matches, so postdeploy bot gate fires for code-initialized activity agents just like manifest agents - add validateProtocolSelection to reject combining activity_protocol with other protocols (activity agent exposes only the activity endpoint); enforced in both flag and interactive paths - add ActivityAgentEndpoint helper + unit tests The azd init flow is otherwise unchanged for non-activity agents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_from_code.go | 40 +++++++++++++++++++ .../internal/cmd/init_from_code_test.go | 16 ++++++++ .../internal/project/activity_profile.go | 15 +++++++ .../internal/project/activity_profile_test.go | 22 ++++++++++ 4 files changed, 93 insertions(+) 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 b538dc8e7e7..7e584d87741 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 @@ -6,6 +6,7 @@ package cmd import ( "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "context" @@ -545,6 +546,15 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) CodeConfiguration: codeConfig, } + // An activity-protocol (Teams) agent additionally advertises the friendly + // "activity" endpoint guarded by BotServiceRbac. Injecting it here mirrors + // the manifest-based init so the generated azure.yaml is identical and + // `azd deploy` provisions the Teams bot. Phase 1 covers the simple use case; + // digital-worker is a Phase 2 addition. No-op for non-activity agents. + if project.IsActivityProtocol(*definition) { + definition.AgentEndpoint = project.ActivityAgentEndpoint() + } + // Add model resource if a model was selected if existingDeployment != nil { // Existing deployment: reference it by name only. Per REFERENCE.md an @@ -893,6 +903,7 @@ type protocolInfo struct { var knownProtocols = []protocolInfo{ {Name: "responses", Version: "2.0.0"}, {Name: "invocations", Version: "1.0.0"}, + {Name: "activity_protocol", Version: "v1"}, } // promptProtocols asks the user which protocols their agent supports. @@ -934,6 +945,9 @@ func promptProtocols( Version: version, }) } + if err := validateProtocolSelection(records); err != nil { + return nil, err + } return records, nil } @@ -994,9 +1008,35 @@ func promptProtocols( ) } + if err := validateProtocolSelection(records); err != nil { + return nil, err + } + return records, nil } +// validateProtocolSelection rejects combining activity_protocol with any other +// protocol. An activity-protocol (Teams) agent exposes only the activity +// endpoint, matching how such agents are declared in a manifest; mixing it with +// responses/invocations would produce an azure.yaml that does not match the +// manifest shape and is not a supported activity configuration. +func validateProtocolSelection(records []agent_yaml.ProtocolVersionRecord) error { + if len(records) < 2 { + return nil + } + for _, r := range records { + if agent_api.AgentProtocol(r.Protocol) == agent_api.AgentProtocolActivityProtocol { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "activity_protocol cannot be combined with other protocols", + "An activity-protocol (Teams) agent exposes only the activity endpoint; "+ + "select activity_protocol on its own.", + ) + } + } + return nil +} + // knownProtocolNames returns a comma-separated list of known protocol names. func knownProtocolNames() string { names := make([]string, 0, len(knownProtocols)) 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 fc5afa774d1..dc3b6ba5db7 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 @@ -619,6 +619,19 @@ func TestPromptProtocols_FlagValues(t *testing.T) { {Protocol: "invocations", Version: "1.0.0"}, }, }, + { + name: "activity_protocol only", + flagProtocols: []string{"activity_protocol"}, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity_protocol", Version: "v1"}, + }, + }, + { + name: "activity_protocol cannot be combined", + flagProtocols: []string{"activity_protocol", "responses"}, + wantErr: true, + wantErrContain: "cannot be combined", + }, } for _, tt := range tests { @@ -681,6 +694,9 @@ func TestKnownProtocolNames(t *testing.T) { if !strings.Contains(result, "invocations") { t.Errorf("knownProtocolNames() = %q, want to contain 'invocations'", result) } + if !strings.Contains(result, "activity_protocol") { + t.Errorf("knownProtocolNames() = %q, want to contain 'activity_protocol'", result) + } } // fakePromptClient is a lightweight test double for azdext.PromptServiceClient. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go index 181cd977301..fa916d48caa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go @@ -64,3 +64,18 @@ func ResolveActivityProfile(ca agent_yaml.ContainerAgent) ActivityProfile { } return ActivityProfile{IsActivity: true, UseCase: ActivityUseCaseSimple} } + +// ActivityAgentEndpoint returns the agent_endpoint declaration an +// activity-protocol (Teams) agent requires: the friendly "activity" protocol +// guarded by the BotServiceRbac authorization scheme. `azd init` uses it to make +// an activity agent scaffolded from local code carry the exact same declaration +// as one initialized from a manifest, so the generated azure.yaml is identical +// and `azd deploy` provisions the Teams bot. Phase 1 covers the simple use case. +func ActivityAgentEndpoint() *agent_yaml.AgentEndpoint { + return &agent_yaml.AgentEndpoint{ + Protocols: []string{string(agent_api.AgentEndpointProtocolActivity)}, + AuthorizationSchemes: []agent_yaml.AuthorizationScheme{ + {Type: string(agent_api.AgentEndpointAuthSchemeBotServiceRbac)}, + }, + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go index 5116d59a257..f82059e1278 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -152,3 +152,25 @@ func TestActivityDeclarationSurvivesInitRoundTrip(t *testing.T) { require.Len(t, got.AgentEndpoint.AuthorizationSchemes, 1) require.Equal(t, "BotServiceRbac", got.AgentEndpoint.AuthorizationSchemes[0].Type) } + +// TestActivityAgentEndpoint pins the endpoint declaration that `azd init` from +// local code injects for an activity_protocol agent. It must match the +// manifest-based shape (friendly "activity" protocol guarded by BotServiceRbac) +// so the two init paths produce identical azure.yaml and both satisfy deploy. +func TestActivityAgentEndpoint(t *testing.T) { + ep := ActivityAgentEndpoint() + require.NotNil(t, ep) + require.Equal(t, []string{"activity"}, ep.Protocols) + require.Len(t, ep.AuthorizationSchemes, 1) + require.Equal(t, "BotServiceRbac", ep.AuthorizationSchemes[0].Type) + + // A definition assembled the way init_from_code does (protocol record + + // injected endpoint) must be recognized as an activity agent. + ca := agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "echo"}, + Protocols: []agent_yaml.ProtocolVersionRecord{{Protocol: "activity_protocol", Version: "v1"}}, + AgentEndpoint: ActivityAgentEndpoint(), + } + require.True(t, IsActivityProtocol(ca)) + require.Equal(t, ActivityUseCaseSimple, ResolveActivityProfile(ca).UseCase) +} From 9a01f03af54e69f5de5b069b1ccfbee9299cfd2f Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 17:20:43 +0800 Subject: [PATCH 03/17] fix(ai.agents): satisfy go-fix modernization and cspell CI - go fix: use new(x) for pointer-to-value and range-over-int (go 1.26) in botservice.go / botservice_test.go - cspell: add activity/bot terms (botservice, armbotservice, Azurebot, idempotently, sideload, behaviour) to the extension dictionary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/cspell.yaml | 7 +++++++ .../internal/pkg/botservice/botservice.go | 16 ++++++++-------- .../internal/pkg/botservice/botservice_test.go | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 8a52f71191b..0a8925eadf5 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -100,3 +100,10 @@ words: - testdir # Test infrastructure - recordproxy + # Activity protocol / Teams bot terms + - armbotservice + - botservice + - Azurebot + - idempotently + - sideload + - behaviour diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go index 9abf37b8f82..12ee7bdf4a9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go @@ -149,15 +149,15 @@ func (c *Client) EnsureBot(ctx context.Context, cfg BotConfig) error { } bot := armbotservice.Bot{ - Location: to.Ptr(botLocation), + Location: new(botLocation), Kind: to.Ptr(armbotservice.KindAzurebot), SKU: &armbotservice.SKU{Name: to.Ptr(armbotservice.SKUNameF0)}, Properties: &armbotservice.BotProperties{ - DisplayName: to.Ptr(cfg.displayName()), - Endpoint: to.Ptr(cfg.MessagingEndpoint), - MsaAppID: to.Ptr(cfg.MsaAppID), + DisplayName: new(cfg.displayName()), + Endpoint: new(cfg.MessagingEndpoint), + MsaAppID: new(cfg.MsaAppID), MsaAppType: to.Ptr(armbotservice.MsaAppTypeSingleTenant), - MsaAppTenantID: to.Ptr(cfg.TenantID), + MsaAppTenantID: new(cfg.TenantID), }, } @@ -170,11 +170,11 @@ func (c *Client) EnsureBot(ctx context.Context, cfg BotConfig) error { func (c *Client) ensureTeamsChannel(ctx context.Context, resourceGroup, botName string) error { channel := armbotservice.BotChannel{ - Location: to.Ptr(botLocation), + Location: new(botLocation), Properties: &armbotservice.MsTeamsChannel{ - ChannelName: to.Ptr(teamsChannelName), + ChannelName: new(teamsChannelName), Properties: &armbotservice.MsTeamsChannelProperties{ - IsEnabled: to.Ptr(true), + IsEnabled: new(true), }, }, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go index 50e19606ece..f0c66855c82 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go @@ -100,7 +100,7 @@ func TestEnsureBotIsIdempotentAcrossRuns(t *testing.T) { channels := &fakeChannels{} c := &Client{bots: bots, channels: channels} - for i := 0; i < 3; i++ { + for i := range 3 { if err := c.EnsureBot(context.Background(), validConfig()); err != nil { t.Fatalf("run %d: EnsureBot error: %v", i, err) } From 60c04d1c7f0d6fe37b9520b00b250d26d15d8233 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 17:47:03 +0800 Subject: [PATCH 04/17] feat(ai.agents): write generic Teams app setup guide after activity deploy Replace the sample-specific, console-only Teams next-steps hint with a generic TEAMS_APP_SETUP.md written next to the agent source during postdeploy. The guide is built only from runtime values (agent name, bot name, msaAppId) and links to official Microsoft Learn docs for packaging and sideloading a Teams app, so it works for any activity agent regardless of source (init-from-manifest or init-from-code) and is a no-op for non-activity deployments. Writing a file (not stdout) guarantees the user reliably receives the guidance, since azd's progress UI does not surface postdeploy handler stdout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/cspell.yaml | 1 + .../internal/cmd/listen_activity.go | 101 +++++++++++++++--- .../internal/cmd/listen_activity_test.go | 60 +++++++++++ 3 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 0a8925eadf5..7bbefa6c0db 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -107,3 +107,4 @@ words: - idempotently - sideload - behaviour + - microsoftteams diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index c7258894bad..e65c57723a7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -7,9 +7,11 @@ import ( "context" "fmt" "log" + "os" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/botservice" + "azureaiagent/internal/pkg/paths" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -94,24 +96,97 @@ func ensureActivityBot( return err } - printTeamsNextSteps(agentName, botName) + // Write a persistent, generic setup guide next to the agent code (the azd + // progress UI swallows postdeploy stdout, so a file is the reliable way to + // hand the user the manual M365 steps) and print a short pointer to it. + guidePath := writeTeamsSetupGuide(proj, svc, agentName, botName, msaAppID) + printTeamsNextSteps(botName, msaAppID, guidePath) return nil } -// printTeamsNextSteps prints the manual, out-of-azd steps needed to make the -// Teams bot reachable from a Teams client: package the Teams app (botId = the -// bot's msaAppId) and sideload/install it. These live on the M365 plane. -func printTeamsNextSteps(agentName, botName string) { +// teamsSetupGuideFile is the name of the generated Teams onboarding guide. +const teamsSetupGuideFile = "TEAMS_APP_SETUP.md" + +// writeTeamsSetupGuide writes a generic, simplified Teams onboarding guide next +// to the agent source so the user can package and sideload their Teams app after +// deploy. It returns the written path, or "" on any failure (best-effort: never +// blocks or fails the deploy). The guide is deploy-agnostic and links to the +// official Microsoft Learn docs rather than any sample-specific scripts. +func writeTeamsSetupGuide( + proj *azdext.ProjectConfig, svc *azdext.ServiceConfig, agentName, botName, msaAppID string, +) string { + guidePath, err := paths.JoinAllowRoot(proj.GetPath(), svc.GetRelativePath(), teamsSetupGuideFile) + if err != nil { + log.Printf("postdeploy: skipping Teams setup guide: %v", err) + return "" + } + if err := os.WriteFile(guidePath, []byte(teamsSetupGuideContent(agentName, botName, msaAppID)), 0o644); err != nil { + log.Printf("postdeploy: failed to write Teams setup guide %q: %v", guidePath, err) + return "" + } + return guidePath +} + +// teamsSetupGuideContent renders the Teams onboarding guide markdown. The single +// value the user must not get wrong is the bot id: a Teams app manifest's +// bots[].id MUST equal this bot's msaAppId, which azd bound to the agent +// instance identity. +func teamsSetupGuideContent(agentName, botName, msaAppID string) string { + return fmt.Sprintf(`# Connect %[1]s to Microsoft Teams + +`+"`azd deploy`"+` provisioned the Azure resources for you: + +- Azure Bot: `+"`%[2]s`"+` (Microsoft Teams channel enabled) +- Bot ID (msaAppId): `+"`%[3]s`"+` + +Two manual steps remain on the Microsoft 365 side. They are the same for any +activity-protocol agent. + +## 1. Package the Teams app + +Build a Teams app package (a .zip containing manifest.json + a color and outline +icon). In the manifest, set the bot id to the value above: + +`+"```json"+` +"bots": [{ "botId": "%[3]s", "scopes": ["personal"] }] +`+"```"+` + +- App package overview: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/apps-package +- Manifest schema: https://learn.microsoft.com/microsoftteams/platform/resources/schema/manifest-schema +- Bots in Teams: https://learn.microsoft.com/microsoftteams/platform/bots/how-to/create-a-bot-for-teams + +Tip: the Microsoft 365 Agents Toolkit can scaffold and package the manifest for you: +https://learn.microsoft.com/microsoftteams/platform/toolkit/agents-toolkit-fundamentals + +## 2. Sideload (upload) the app + +Enable custom app upload for your tenant, then upload the package in Teams: +Apps -> Manage your apps -> Upload an app -> Upload a custom app. + +- Upload a custom app: https://learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload +- Enable custom app upload: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant + +Once uploaded, open the app in Teams and send a message to talk to your agent. +`, agentName, botName, msaAppID) +} + +// printTeamsNextSteps prints a short pointer to the generated setup guide. The +// full instructions live in the guide file because the azd progress UI does not +// reliably surface postdeploy stdout. +func printTeamsNextSteps(botName, msaAppID, guidePath string) { fmt.Println(output.WithHighLightFormat("\nTeams bot ready.")) fmt.Printf(" Azure Bot: %s (Microsoft Teams channel enabled)\n", botName) - fmt.Println("\nNext steps (outside azd — Teams app packaging & install):") - fmt.Println(output.WithGrayFormat( - " 1. Package the Teams app with botId = the bot's msaAppId " + - "(see scripts/package-teams-app.ps1 or the Microsoft 365 Agents Toolkit).", - )) - fmt.Println(output.WithGrayFormat( - " 2. Sideload it: Teams -> Apps -> Manage your apps -> Upload a custom app.", - )) + fmt.Printf(" Bot ID: %s\n", msaAppID) + if guidePath != "" { + fmt.Println(output.WithGrayFormat(fmt.Sprintf( + " Next steps (package + sideload the Teams app): see %s", guidePath, + ))) + } else { + fmt.Println(output.WithGrayFormat( + " Next steps: package the Teams app (bots[].id = the Bot ID above) and " + + "upload it in Teams -> Apps -> Manage your apps -> Upload a custom app.", + )) + } } // readEnvValue reads a required environment value, returning a descriptive error diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go new file mode 100644 index 00000000000..f0a748c281e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func TestTeamsSetupGuideContent(t *testing.T) { + const msaAppID = "11111111-2222-3333-4444-555555555555" + content := teamsSetupGuideContent("echo-agent", "echo-agent-bot-uai", msaAppID) + + // The bot id is the one value the user must not get wrong: it has to be + // carried verbatim into the Teams manifest bots[].id. + if !strings.Contains(content, `"botId": "`+msaAppID+`"`) { + t.Fatalf("guide must set bots[].botId to the msaAppId; got:\n%s", content) + } + + // The guide must point at the official Microsoft Learn docs, not any + // sample-specific script. + for _, link := range []string{ + "learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/apps-package", + "learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload", + } { + if !strings.Contains(content, link) { + t.Errorf("guide missing official doc link %q", link) + } + } + if strings.Contains(content, "package-teams-app.ps1") { + t.Errorf("guide must not reference sample-specific scripts") + } +} + +func TestWriteTeamsSetupGuide(t *testing.T) { + root := t.TempDir() + proj := &azdext.ProjectConfig{Path: root} + svc := &azdext.ServiceConfig{Name: "echo-agent", RelativePath: "src"} + if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { + t.Fatal(err) + } + + path := writeTeamsSetupGuide(proj, svc, "echo-agent", "echo-agent-bot-uai", "app-id") + want := filepath.Join(root, "src", teamsSetupGuideFile) + if path != want { + t.Fatalf("guide path = %q, want %q", path, want) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("guide not written: %v", err) + } + if !strings.Contains(string(data), "app-id") { + t.Errorf("written guide missing bot id") + } +} From cb0a2b70946af70b0e69b54fb2302da38d50b425 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 17:50:58 +0800 Subject: [PATCH 05/17] fix(ai.agents): tighten file/dir perms for gosec (G306/G301) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/listen_activity.go | 2 +- .../azure.ai.agents/internal/cmd/listen_activity_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index e65c57723a7..1a63f2a18d8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -120,7 +120,7 @@ func writeTeamsSetupGuide( log.Printf("postdeploy: skipping Teams setup guide: %v", err) return "" } - if err := os.WriteFile(guidePath, []byte(teamsSetupGuideContent(agentName, botName, msaAppID)), 0o644); err != nil { + if err := os.WriteFile(guidePath, []byte(teamsSetupGuideContent(agentName, botName, msaAppID)), 0o600); err != nil { log.Printf("postdeploy: failed to write Teams setup guide %q: %v", guidePath, err) return "" } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go index f0a748c281e..8a5d1d04e5d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go @@ -41,7 +41,7 @@ func TestWriteTeamsSetupGuide(t *testing.T) { root := t.TempDir() proj := &azdext.ProjectConfig{Path: root} svc := &azdext.ServiceConfig{Name: "echo-agent", RelativePath: "src"} - if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(root, "src"), 0o750); err != nil { t.Fatal(err) } From ef1808ae59a48921ee502b3744bf97214fb1aa69 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 18:07:07 +0800 Subject: [PATCH 06/17] fix(ai.agents): globally-unique bot name + clearer postdeploy error Address peer-review feedback on the activity-protocol bot flow: - Salt the Azure Bot resource name with a hash of the deployment scope (subscription + resource group). BotService names are globally unique, so two environments deploying an agent with the same name previously collided; the name stays deterministic per scope so redeploys still update the same bot. Long agent names are truncated to the BotService handle limit. Create and teardown share BotScopeSalt so the names match. - On postdeploy bot-config failure, make the error state that the agent version deployed successfully and only the Teams channel binding failed (commonly permissions/quota), and to re-run 'azd deploy'. - Rename TestEnsureBotIsIdempotentAcrossRuns to TestEnsureBotIssuesUpsertOnEveryRun and clarify it asserts an upsert each run, not server-side idempotency; expand TestBotName to cover determinism, per-scope uniqueness, and length capping. - Clarify the messaging-endpoint api-version constant is intentionally independent of the deploy api-version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/cspell.yaml | 1 + .../azure.ai.agents/internal/cmd/listen.go | 7 ++- .../internal/cmd/listen_activity.go | 4 +- .../internal/pkg/botservice/botservice.go | 45 +++++++++++++++---- .../pkg/botservice/botservice_test.go | 27 ++++++++--- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 7bbefa6c0db..f6851414ae3 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -108,3 +108,4 @@ words: - sideload - behaviour - microsoftteams + - upsert diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 68bba80a4a5..6cb30a49afa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -386,7 +386,12 @@ func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *a ctx, azdClient, cred, envName, svc, args.Project, endpointResp.Value, tenantResp.Value, versionObj, ); err != nil { - return fmt.Errorf("failed to configure Teams bot for agent %q: %w", svc.Name, err) + return fmt.Errorf( + "agent %q deployed successfully, but configuring its Microsoft Teams bot failed: %w\n"+ + " The agent version is active — only the Teams channel binding is missing "+ + "(commonly Azure Bot permissions or quota). Resolve the cause and re-run 'azd deploy'.", + svc.Name, err, + ) } // Report optimization candidate deployment (best-effort: panics are logged, not propagated). diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index 1a63f2a18d8..ef7e06fab40 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -80,7 +80,7 @@ func ensureActivityBot( // The API agent name is the service name (deploy fetched the version with it), // so the messaging endpoint and bot name must use the same value. agentName := svc.Name - botName := botservice.BotName(agentName) + botName := botservice.BotName(agentName, botservice.BotScopeSalt(subscriptionID, resourceGroup)) cfg := botservice.BotConfig{ ResourceGroup: resourceGroup, @@ -265,7 +265,7 @@ func teardownActivityBots( } for _, agentName := range activityAgents { - botName := botservice.BotName(agentName) + botName := botservice.BotName(agentName, botservice.BotScopeSalt(subscriptionID, resourceGroup)) if err := botClient.DeleteBot(ctx, resourceGroup, botName); err != nil { log.Printf("postdown: failed to delete Azure Bot %q: %v", botName, err) continue diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go index 12ee7bdf4a9..d835b85e6fc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go @@ -10,6 +10,8 @@ package botservice import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/http" @@ -29,11 +31,20 @@ const ( // carries in its ChannelName field. teamsChannelName = "MsTeamsChannel" // messagingEndpointAPIVersion is the api-version the activity-protocol - // messaging endpoint URL is pinned to (verified against the POC). + // messaging endpoint URL is pinned to (verified against the POC). This is the + // BotService messaging-endpoint contract and is intentionally independent of + // the agent-plane deploy api-version used elsewhere; the two evolve + // separately, so keep them as distinct constants rather than sharing one. messagingEndpointAPIVersion = "2025-05-15-preview" - // botNameSuffix is appended to the agent name to form the bot resource name. - // BotService names are globally unique, so teardown must delete the bot. - botNameSuffix = "-bot-uai" + // botNameSuffix separates the agent name from the uniqueness token in the bot + // resource name. + botNameSuffix = "-bot-" + // botNameTokenLen is the number of hex chars of the scope hash appended to the + // bot name to keep it globally unique. + botNameTokenLen = 8 + // botNameMaxLen caps the bot resource name length (Azure BotService handle + // limit) so a long agent name cannot push it over the limit. + botNameMaxLen = 42 ) // botsAPI and channelsAPI are the narrow slices of the armbotservice clients this @@ -80,11 +91,27 @@ func NewClient( return &Client{bots: bots, channels: channels}, nil } -// BotName returns the conventional Azure Bot resource name for an agent. It -// matches the name used by the sample's setup-instance-bot.ps1 so an existing -// bot is reused rather than duplicated. -func BotName(agentName string) string { - return agentName + botNameSuffix +// BotName returns a deterministic Azure Bot resource name for an agent. Because +// BotService resource names are globally unique across all of Azure, the name is +// salted with a short hash of the deployment scope (subscription + resource +// group) so that two environments deploying an agent with the same name do not +// collide. The name is stable for a given scope, so redeploys update the same bot +// rather than creating a new one. +func BotName(agentName, scopeSalt string) string { + sum := sha256.Sum256([]byte(scopeSalt)) + suffix := botNameSuffix + hex.EncodeToString(sum[:])[:botNameTokenLen] + if maxAgent := botNameMaxLen - len(suffix); len(agentName) > maxAgent { + agentName = agentName[:maxAgent] + } + // Avoid a doubled hyphen if truncation (or the agent name) leaves a trailing '-'. + return strings.TrimRight(agentName, "-") + suffix +} + +// BotScopeSalt builds the deployment-scope salt for BotName from the subscription +// and resource group. Callers that create and later delete the same bot must use +// the same salt so the names match. +func BotScopeSalt(subscriptionID, resourceGroup string) string { + return subscriptionID + "/" + resourceGroup } // MessagingEndpoint returns the activity-protocol messaging endpoint URL the bot diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go index f0c66855c82..8c9ec27e4d2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "net/http" + "strings" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -95,7 +96,7 @@ func TestEnsureBotCreatesSingleTenantBotAndTeamsChannel(t *testing.T) { } } -func TestEnsureBotIsIdempotentAcrossRuns(t *testing.T) { +func TestEnsureBotIssuesUpsertOnEveryRun(t *testing.T) { bots := &fakeBots{} channels := &fakeChannels{} c := &Client{bots: bots, channels: channels} @@ -105,8 +106,9 @@ func TestEnsureBotIsIdempotentAcrossRuns(t *testing.T) { t.Fatalf("run %d: EnsureBot error: %v", i, err) } } - // Create is a PUT (create-or-update); re-running is safe and re-applies - // the same desired state each time. + // EnsureBot uses a PUT (create-or-update) each run, so re-running is safe and + // re-applies the same desired state every time. This asserts the upsert is + // issued on every run, not server-side resource idempotency. if len(bots.createCalls) != 3 || len(channels.createCalls) != 3 { t.Errorf("expected 3 bot + 3 channel calls, got %d + %d", len(bots.createCalls), len(channels.createCalls)) @@ -145,8 +147,23 @@ func TestDeleteBotPropagatesOtherErrors(t *testing.T) { } func TestBotName(t *testing.T) { - if got := BotName("echo28ju3pm"); got != "echo28ju3pm-bot-uai" { - t.Errorf("BotName = %q, want echo28ju3pm-bot-uai", got) + salt := BotScopeSalt("sub-1", "rg-1") + got := BotName("echo", salt) + + // Deterministic for a given scope, so redeploys update the same bot. + if got2 := BotName("echo", salt); got != got2 { + t.Errorf("BotName not deterministic: %q vs %q", got, got2) + } + // A different scope must yield a different name to avoid global collisions. + if other := BotName("echo", BotScopeSalt("sub-2", "rg-1")); other == got { + t.Errorf("BotName should differ across scopes; both were %q", got) + } + if !strings.HasPrefix(got, "echo-bot-") { + t.Errorf("BotName = %q, want prefix echo-bot-", got) + } + // A long agent name is truncated so the resource name stays within the limit. + if n := BotName(strings.Repeat("a", 100), salt); len(n) > botNameMaxLen { + t.Errorf("BotName %q exceeds max length %d", n, botNameMaxLen) } } From e2a76c68f9c6df2bb9d10676d1d694372cb89eb2 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 19:52:32 +0800 Subject: [PATCH 07/17] docs(ai.agents): give concrete step-by-step Teams packaging + sideload guide Rewrite the generated TEAMS_APP_SETUP.md to include direct, minimal steps instead of only linking out: an easiest path (Teams Developer Portal, reuse the existing Azure bot by Bot ID, download a ready .zip) and a by-hand path (three-file list, a copy-paste minimal manifest.json noting that app id is a new GUID distinct from botId), plus numbered sideload steps (Apps -> Manage your apps -> Upload an app -> Upload a custom app). Each step keeps a link to the official Microsoft Learn doc for detail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/listen_activity.go | 82 +++++++++++++------ .../internal/cmd/listen_activity_test.go | 5 ++ 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index ef7e06fab40..d2503940f21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -127,46 +127,82 @@ func writeTeamsSetupGuide( return guidePath } -// teamsSetupGuideContent renders the Teams onboarding guide markdown. The single -// value the user must not get wrong is the bot id: a Teams app manifest's -// bots[].id MUST equal this bot's msaAppId, which azd bound to the agent -// instance identity. +// teamsSetupGuideContent renders the Teams onboarding guide markdown. It gives +// concrete, minimal step-by-step instructions for the two manual actions +// (package the Teams app, then sideload it) and links to the official docs for +// detail. The single value the user must not get wrong is the bot id: a Teams +// app manifest's bots[].botId MUST equal this bot's msaAppId, which azd bound to +// the agent instance identity. func teamsSetupGuideContent(agentName, botName, msaAppID string) string { return fmt.Sprintf(`# Connect %[1]s to Microsoft Teams -`+"`azd deploy`"+` provisioned the Azure resources for you: +`+"`azd deploy`"+` already did the Azure side for you: - Azure Bot: `+"`%[2]s`"+` (Microsoft Teams channel enabled) -- Bot ID (msaAppId): `+"`%[3]s`"+` +- Bot ID (msaAppId): `+"`%[3]s`"+` <- you will paste this as the bot id -Two manual steps remain on the Microsoft 365 side. They are the same for any -activity-protocol agent. +Two manual steps remain: (A) create a Teams app package, then (B) upload it. +They are the same for any activity-protocol agent. -## 1. Package the Teams app +## A. Create the Teams app package -Build a Teams app package (a .zip containing manifest.json + a color and outline -icon). In the manifest, set the bot id to the value above: +Pick ONE of the two ways below. + +### Easiest — Teams Developer Portal (no files by hand) + +1. Open https://dev.teams.microsoft.com/apps and select **+ New app**; enter a name. +2. Fill **Basic information** (short/long description, developer name and URLs). +3. Left menu **App features** -> **Bot** -> **Select an existing bot** -> enter the + Bot ID `+"`%[3]s`"+`, tick the **Personal** scope, then **Save**. +4. **Publish** -> **Download the app package** — this gives you a ready-to-upload .zip. + +Developer Portal guide: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/teams-developer-portal + +### Or by hand — build the .zip yourself + +Put these three files in a folder and zip them at the **root** (not inside a subfolder): + +- `+"`manifest.json`"+` (below) +- `+"`color.png`"+` — 192x192 px +- `+"`outline.png`"+` — 32x32 px, transparent background `+"```json"+` -"bots": [{ "botId": "%[3]s", "scopes": ["personal"] }] +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.19/MicrosoftTeams.schema.json", + "manifestVersion": "1.19", + "version": "1.0.0", + "id": "REPLACE-WITH-A-NEW-GUID", + "developer": { + "name": "Your Company", + "websiteUrl": "https://example.com", + "privacyUrl": "https://example.com/privacy", + "termsOfUseUrl": "https://example.com/terms" + }, + "name": { "short": "%[1]s", "full": "%[1]s" }, + "description": { "short": "%[1]s agent", "full": "%[1]s agent on Microsoft Teams" }, + "icons": { "color": "color.png", "outline": "outline.png" }, + "accentColor": "#FFFFFF", + "bots": [{ "botId": "%[3]s", "scopes": ["personal"] }] +} `+"```"+` -- App package overview: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/apps-package -- Manifest schema: https://learn.microsoft.com/microsoftteams/platform/resources/schema/manifest-schema -- Bots in Teams: https://learn.microsoft.com/microsoftteams/platform/bots/how-to/create-a-bot-for-teams +Note: `+"`id`"+` is a NEW GUID for the app itself (generate one) — it is NOT the Bot ID. +Only `+"`bots[].botId`"+` uses the Bot ID above. -Tip: the Microsoft 365 Agents Toolkit can scaffold and package the manifest for you: -https://learn.microsoft.com/microsoftteams/platform/toolkit/agents-toolkit-fundamentals +- Package + icon requirements: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/apps-package +- Manifest schema reference: https://learn.microsoft.com/microsoftteams/platform/resources/schema/manifest-schema +- Validate your .zip before uploading: https://dev.teams.microsoft.com/tools/store-validation -## 2. Sideload (upload) the app +## B. Upload (sideload) the app -Enable custom app upload for your tenant, then upload the package in Teams: -Apps -> Manage your apps -> Upload an app -> Upload a custom app. +Prerequisite: your tenant must allow custom app upload — a Teams admin enables it once: +https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant -- Upload a custom app: https://learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload -- Enable custom app upload: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant +1. In Teams, go to **Apps** -> **Manage your apps** -> **Upload an app**. +2. Select **Upload a custom app**, choose your .zip, then **Add**. +3. Select **Open**, then send a message to talk to your agent. -Once uploaded, open the app in Teams and send a message to talk to your agent. +Upload a custom app guide: https://learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload `, agentName, botName, msaAppID) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go index 8a5d1d04e5d..8bf9e616714 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go @@ -27,11 +27,16 @@ func TestTeamsSetupGuideContent(t *testing.T) { for _, link := range []string{ "learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/apps-package", "learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload", + "dev.teams.microsoft.com/apps", } { if !strings.Contains(content, link) { t.Errorf("guide missing official doc link %q", link) } } + // The guide must give the concrete sideload step, not just link out. + if !strings.Contains(content, "Upload a custom app") { + t.Errorf("guide missing the concrete sideload step") + } if strings.Contains(content, "package-teams-app.ps1") { t.Errorf("guide must not reference sample-specific scripts") } From 6cbb3bfaf7f93390a1134d00eed4770be1639e20 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 20:30:25 +0800 Subject: [PATCH 08/17] docs(ai.agents): make Teams sideload self-serve; tenant admin only as fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/listen_activity.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index d2503940f21..3ada29ffa05 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -193,16 +193,19 @@ Only `+"`bots[].botId`"+` uses the Bot ID above. - Manifest schema reference: https://learn.microsoft.com/microsoftteams/platform/resources/schema/manifest-schema - Validate your .zip before uploading: https://dev.teams.microsoft.com/tools/store-validation -## B. Upload (sideload) the app +## B. Upload (sideload) the app — just for yourself -Prerequisite: your tenant must allow custom app upload — a Teams admin enables it once: -https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant +You do NOT need a Teams admin to try it yourself: 1. In Teams, go to **Apps** -> **Manage your apps** -> **Upload an app**. 2. Select **Upload a custom app**, choose your .zip, then **Add**. 3. Select **Open**, then send a message to talk to your agent. Upload a custom app guide: https://learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload + +If **Upload a custom app** is missing or greyed out, custom app upload is turned off for +your tenant, or you want everyone in your org to get it from the org app catalog. Both need +a Teams admin: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant `, agentName, botName, msaAppID) } From bd851b50043ba100ea4e741743bea6ff8382aa4b Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 20:42:51 +0800 Subject: [PATCH 09/17] docs(ai.agents): add optional CLI path (zip package + atk sideload) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/cspell.yaml | 2 ++ .../internal/cmd/listen_activity.go | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index f6851414ae3..8aec926258a 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -109,3 +109,5 @@ words: - behaviour - microsoftteams - upsert + - m365agentstoolkit + - atk diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index 3ada29ffa05..236b581a02c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -206,6 +206,33 @@ Upload a custom app guide: https://learn.microsoft.com/microsoftteams/platform/c If **Upload a custom app** is missing or greyed out, custom app upload is turned off for your tenant, or you want everyone in your org to get it from the org app catalog. Both need a Teams admin: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant + +## C. Optional — do both from the command line + +Steps A and B can be scripted. This is a convenience path for repeat runs; it needs extra +tooling and does NOT bypass the tenant custom-app-upload setting above. + +Package: put the manifest.json from section A (its Bot ID is already filled in) next to your +two icons, then zip the three files at the root: + +`+"```"+`sh +zip -j %[1]s-teams-app.zip manifest.json color.png outline.png # bash +`+"```"+` +`+"```"+`powershell +Compress-Archive manifest.json,color.png,outline.png %[1]s-teams-app.zip # PowerShell +`+"```"+` + +Sideload for yourself with the Microsoft 365 Agents Toolkit CLI (atk). ` + "`--scope Personal`" + ` is a +per-user install and needs NO Teams admin: + +`+"```"+`sh +npm install -g @microsoft/m365agentstoolkit-cli # one-time; requires Node.js +atk auth login # sign in with your M365 account +atk install --file-path %[1]s-teams-app.zip --scope Personal +`+"```"+` + +atk prints a TitleId and a Teams deep link you can open to launch the agent. +atk CLI reference: https://learn.microsoft.com/microsoftteams/platform/toolkit/microsoft-365-agents-toolkit-cli `, agentName, botName, msaAppID) } From 4258194a89f9179f7ba2442440644cf46897a79a Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 2 Jul 2026 20:59:10 +0800 Subject: [PATCH 10/17] style(ai.agents): gofmt string concatenation in Teams guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/listen_activity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index 236b581a02c..08c8d70f835 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -222,7 +222,7 @@ zip -j %[1]s-teams-app.zip manifest.json color.png outline.png # bash Compress-Archive manifest.json,color.png,outline.png %[1]s-teams-app.zip # PowerShell `+"```"+` -Sideload for yourself with the Microsoft 365 Agents Toolkit CLI (atk). ` + "`--scope Personal`" + ` is a +Sideload for yourself with the Microsoft 365 Agents Toolkit CLI (atk). `+"`--scope Personal`"+` is a per-user install and needs NO Teams admin: `+"```"+`sh From 0e67902b8665f76311e49482b545e7a73a4cd3c5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:03:16 +0800 Subject: [PATCH 11/17] Address Copilot review nits: bots[].botId wording, errors.AsType, behavior spelling - Fix runtime/test message to reference Teams manifest bots[].botId (not bots[].id) - Use Go 1.26 errors.AsType helper in botservice.isNotFound for consistency - Use American spelling 'behavior' and drop the cspell behaviour allow-entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/cspell.yaml | 1 - .../azure.ai.agents/internal/cmd/listen_activity.go | 2 +- .../azure.ai.agents/internal/cmd/listen_activity_test.go | 2 +- .../azure.ai.agents/internal/pkg/botservice/botservice.go | 4 ++-- .../azure.ai.agents/internal/project/activity_profile.go | 2 +- .../azure.ai.agents/internal/project/activity_profile_test.go | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 8aec926258a..08f82003a17 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -106,7 +106,6 @@ words: - Azurebot - idempotently - sideload - - behaviour - microsoftteams - upsert - m365agentstoolkit diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index c3a00936130..af5c4e67b68 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -278,7 +278,7 @@ func printTeamsNextSteps(botName, msaAppID, guidePath string) { ))) } else { fmt.Println(output.WithGrayFormat( - " Next steps: package the Teams app (bots[].id = the Bot ID above) and " + + " Next steps: package the Teams app (bots[].botId = the Bot ID above) and " + "upload it in Teams -> Apps -> Manage your apps -> Upload a custom app.", )) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go index 8bf9e616714..86e53054e10 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go @@ -17,7 +17,7 @@ func TestTeamsSetupGuideContent(t *testing.T) { content := teamsSetupGuideContent("echo-agent", "echo-agent-bot-uai", msaAppID) // The bot id is the one value the user must not get wrong: it has to be - // carried verbatim into the Teams manifest bots[].id. + // carried verbatim into the Teams manifest bots[].botId. if !strings.Contains(content, `"botId": "`+msaAppID+`"`) { t.Fatalf("guide must set bots[].botId to the msaAppId; got:\n%s", content) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go index d835b85e6fc..320c7d24b06 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go @@ -227,6 +227,6 @@ func (c *Client) DeleteBot(ctx context.Context, resourceGroup, botName string) e } func isNotFound(err error) bool { - var respErr *azcore.ResponseError - return errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound + respErr, ok := errors.AsType[*azcore.ResponseError](err) + return ok && respErr.StatusCode == http.StatusNotFound } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go index fa916d48caa..07b72038ae5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go @@ -26,7 +26,7 @@ const ( // ActivityProfile summarizes the activity-protocol characteristics of a hosted // agent definition. It is the single gate that keeps all Teams/bot-specific -// behaviour off the path of non-activity agents: when IsActivity is false the +// behavior off the path of non-activity agents: when IsActivity is false the // native provision/deploy flow is completely unchanged. type ActivityProfile struct { // IsActivity reports whether the agent opts into the Activity protocol. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go index f82059e1278..a235db3de87 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -105,7 +105,7 @@ func TestResolveActivityProfile(t *testing.T) { }) } -// TestActivityDeclarationSurvivesInitRoundTrip locks the behaviour that +// TestActivityDeclarationSurvivesInitRoundTrip locks the behavior that // `azd ai agent init` preserves the activity-protocol declaration (container // protocols + agent_endpoint) when it moves an agent definition into azure.yaml // service properties. Without this, postdeploy's activity gate would never fire From ea1abeeef5398ab222283150888fb9282d5a8e6c Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:59:03 +0800 Subject: [PATCH 12/17] fix(ai.agents): address trangevi review on activity-protocol PR - Drop Teams-specific wording from protocol/endpoint-layer comments; the Activity protocol is not unique to Teams - Use activity protocol version 1.0.0 for consistency with other protocols (routing-equivalent to v1) - Emit canonical protocol name "activity" instead of redundant "activity_protocol"; keep the legacy value accepted for back-compat - Rewrite validateProtocolSelection doc/error to state the real reason (ActivityAgentEndpoint replaces AgentEndpoint wholesale, cannot compose) - Clarify ensureActivityBot / EnsureBot / ensureTeamsChannel docs: azd provisions the Azure Bot + resource-plane Teams channel (a required connector bound to the agent identity), not the M365 Teams app - Move the embedded Teams setup guide into assets/teams_app_setup_guide.md rendered via go:embed + text/template; rendered output is byte-identical Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/cspell.yaml | 1 + .../cmd/assets/teams_app_setup_guide.md | 99 +++++++++++++ .../internal/cmd/init_from_code.go | 39 +++-- .../internal/cmd/init_from_code_test.go | 14 +- .../azure.ai.agents/internal/cmd/listen.go | 2 +- .../internal/cmd/listen_activity.go | 137 ++++-------------- .../internal/pkg/agents/agent_api/models.go | 25 +++- .../internal/pkg/botservice/botservice.go | 12 +- .../internal/project/activity_profile.go | 16 +- .../internal/project/activity_profile_test.go | 8 +- .../project/service_target_agent_test.go | 3 +- 11 files changed, 206 insertions(+), 150 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 08f82003a17..84a77939c37 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -106,6 +106,7 @@ words: - Azurebot - idempotently - sideload + - sideloading - microsoftteams - upsert - m365agentstoolkit diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md new file mode 100644 index 00000000000..33d62832087 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md @@ -0,0 +1,99 @@ +# Connect {{.AgentName}} to Microsoft Teams + +`azd deploy` already did the Azure side for you: + +- Azure Bot: `{{.BotName}}` (Microsoft Teams channel enabled) +- Bot ID (msaAppId): `{{.MsaAppID}}` <- you will paste this as the bot id + +Two manual steps remain: (A) create a Teams app package, then (B) upload it. +They are the same for any activity-protocol agent. + +## A. Create the Teams app package + +Pick ONE of the two ways below. + +### Easiest — Teams Developer Portal (no files by hand) + +1. Open https://dev.teams.microsoft.com/apps and select **+ New app**; enter a name. +2. Fill **Basic information** (short/long description, developer name and URLs). +3. Left menu **App features** -> **Bot** -> **Select an existing bot** -> enter the + Bot ID `{{.MsaAppID}}`, tick the **Personal** scope, then **Save**. +4. **Publish** -> **Download the app package** — this gives you a ready-to-upload .zip. + +Developer Portal guide: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/teams-developer-portal + +### Or by hand — build the .zip yourself + +Put these three files in a folder and zip them at the **root** (not inside a subfolder): + +- `manifest.json` (below) +- `color.png` — 192x192 px +- `outline.png` — 32x32 px, transparent background + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.19/MicrosoftTeams.schema.json", + "manifestVersion": "1.19", + "version": "1.0.0", + "id": "REPLACE-WITH-A-NEW-GUID", + "developer": { + "name": "Your Company", + "websiteUrl": "https://example.com", + "privacyUrl": "https://example.com/privacy", + "termsOfUseUrl": "https://example.com/terms" + }, + "name": { "short": "{{.AgentName}}", "full": "{{.AgentName}}" }, + "description": { "short": "{{.AgentName}} agent", "full": "{{.AgentName}} agent on Microsoft Teams" }, + "icons": { "color": "color.png", "outline": "outline.png" }, + "accentColor": "#FFFFFF", + "bots": [{ "botId": "{{.MsaAppID}}", "scopes": ["personal"] }] +} +``` + +Note: `id` is a NEW GUID for the app itself (generate one) — it is NOT the Bot ID. +Only `bots[].botId` uses the Bot ID above. + +- Package + icon requirements: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/apps-package +- Manifest schema reference: https://learn.microsoft.com/microsoftteams/platform/resources/schema/manifest-schema +- Validate your .zip before uploading: https://dev.teams.microsoft.com/tools/store-validation + +## B. Upload (sideload) the app — just for yourself + +You do NOT need a Teams admin to try it yourself: + +1. In Teams, go to **Apps** -> **Manage your apps** -> **Upload an app**. +2. Select **Upload a custom app**, choose your .zip, then **Add**. +3. Select **Open**, then send a message to talk to your agent. + +Upload a custom app guide: https://learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload + +If **Upload a custom app** is missing or greyed out, custom app upload is turned off for +your tenant, or you want everyone in your org to get it from the org app catalog. Both need +a Teams admin: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant + +## C. Optional — do both from the command line + +Steps A and B can be scripted. This is a convenience path for repeat runs; it needs extra +tooling and does NOT bypass the tenant custom-app-upload setting above. + +Package: put the manifest.json from section A (its Bot ID is already filled in) next to your +two icons, then zip the three files at the root: + +```sh +zip -j {{.AgentName}}-teams-app.zip manifest.json color.png outline.png # bash +``` +```powershell +Compress-Archive manifest.json,color.png,outline.png {{.AgentName}}-teams-app.zip # PowerShell +``` + +Sideload for yourself with the Microsoft 365 Agents Toolkit CLI (atk). `--scope Personal` is a +per-user install and needs NO Teams admin: + +```sh +npm install -g @microsoft/m365agentstoolkit-cli # one-time; requires Node.js +atk auth login # sign in with your M365 account +atk install --file-path {{.AgentName}}-teams-app.zip --scope Personal +``` + +atk prints a TitleId and a Teams deep link you can open to launch the agent. +atk CLI reference: https://learn.microsoft.com/microsoftteams/platform/toolkit/microsoft-365-agents-toolkit-cli 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 7e584d87741..7ba5f09e952 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 @@ -546,11 +546,11 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) CodeConfiguration: codeConfig, } - // An activity-protocol (Teams) agent additionally advertises the friendly - // "activity" endpoint guarded by BotServiceRbac. Injecting it here mirrors - // the manifest-based init so the generated azure.yaml is identical and - // `azd deploy` provisions the Teams bot. Phase 1 covers the simple use case; - // digital-worker is a Phase 2 addition. No-op for non-activity agents. + // An activity agent additionally advertises the friendly "activity" endpoint + // guarded by BotServiceRbac. Injecting it here mirrors the manifest-based init + // so the generated azure.yaml is identical and `azd deploy` provisions the + // Azure Bot connector. Phase 1 covers the simple use case; digital-worker is a + // Phase 2 addition. No-op for non-activity agents. if project.IsActivityProtocol(*definition) { definition.AgentEndpoint = project.ActivityAgentEndpoint() } @@ -903,7 +903,12 @@ type protocolInfo struct { var knownProtocols = []protocolInfo{ {Name: "responses", Version: "2.0.0"}, {Name: "invocations", Version: "1.0.0"}, - {Name: "activity_protocol", Version: "v1"}, + // "activity" is the canonical protocol name (legacy alias: "activity_protocol"). + // Version is a platform routing selector, not a free version string: "v1" and + // "1.0.0" route to /api/messages, "2.0.0" to /activity/messages. Our sample + // serves /api/messages, so "1.0.0" keeps that routing while matching the + // semver style of responses/invocations. + {Name: "activity", Version: "1.0.0"}, } // promptProtocols asks the user which protocols their agent supports. @@ -1015,22 +1020,26 @@ func promptProtocols( return records, nil } -// validateProtocolSelection rejects combining activity_protocol with any other -// protocol. An activity-protocol (Teams) agent exposes only the activity -// endpoint, matching how such agents are declared in a manifest; mixing it with -// responses/invocations would produce an azure.yaml that does not match the -// manifest shape and is not a supported activity configuration. +// validateProtocolSelection rejects selecting activity alongside any other +// protocol. When an agent speaks the Activity protocol, init injects a single +// agent-level endpoint (ActivityAgentEndpoint — the "activity" endpoint guarded +// by BotServiceRbac) that overrides AgentEndpoint wholesale. That injection only +// models the activity endpoint and can't compose with the endpoints other +// protocols (responses/invocations) require, so combining them would emit an +// azure.yaml that advertises those protocols but wires up only the activity +// endpoint. This is not a platform limitation — the platform allows coexistence; +// it is a Phase-1 scoping choice. func validateProtocolSelection(records []agent_yaml.ProtocolVersionRecord) error { if len(records) < 2 { return nil } for _, r := range records { - if agent_api.AgentProtocol(r.Protocol) == agent_api.AgentProtocolActivityProtocol { + if agent_api.IsActivityProtocolName(agent_api.AgentProtocol(r.Protocol)) { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, - "activity_protocol cannot be combined with other protocols", - "An activity-protocol (Teams) agent exposes only the activity endpoint; "+ - "select activity_protocol on its own.", + "activity cannot be combined with other protocols", + "An activity agent exposes only the activity endpoint; "+ + "select activity on its own.", ) } } 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 dc3b6ba5db7..f351637bc6e 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 @@ -620,15 +620,15 @@ func TestPromptProtocols_FlagValues(t *testing.T) { }, }, { - name: "activity_protocol only", - flagProtocols: []string{"activity_protocol"}, + name: "activity only", + flagProtocols: []string{"activity"}, wantProtocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity_protocol", Version: "v1"}, + {Protocol: "activity", Version: "1.0.0"}, }, }, { - name: "activity_protocol cannot be combined", - flagProtocols: []string{"activity_protocol", "responses"}, + name: "activity cannot be combined", + flagProtocols: []string{"activity", "responses"}, wantErr: true, wantErrContain: "cannot be combined", }, @@ -694,8 +694,8 @@ func TestKnownProtocolNames(t *testing.T) { if !strings.Contains(result, "invocations") { t.Errorf("knownProtocolNames() = %q, want to contain 'invocations'", result) } - if !strings.Contains(result, "activity_protocol") { - t.Errorf("knownProtocolNames() = %q, want to contain 'activity_protocol'", result) + if !strings.Contains(result, "activity") { + t.Errorf("knownProtocolNames() = %q, want to contain 'activity'", result) } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 7d30ba15760..49c76f08a9a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -351,7 +351,7 @@ func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *a return nil } - // For activity-protocol (Teams) agents, provision the Azure Bot + Teams + // For activity agents, provision the Azure Bot + Teams // channel bound to the agent instance identity. No-op for other agents. if err := ensureActivityBot( ctx, azdClient, cred, envName, svc, args.Project, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index af5c4e67b68..cc4804168b5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -4,10 +4,13 @@ package cmd import ( + "bytes" "context" + _ "embed" "fmt" "log" "os" + "text/template" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/botservice" @@ -20,15 +23,16 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" ) -// ensureActivityBot provisions the Azure Bot + Microsoft Teams channel for an -// activity-protocol (Teams) agent during postdeploy. It is a no-op for any agent -// that does not opt into the Activity protocol, so non-activity deployments are +// ensureActivityBot runs during postdeploy for an agent that speaks the Activity +// protocol; it is a no-op for any other agent, so non-activity deployments are // completely unaffected. // -// Scope: azd owns the Azure resource plane only — create the bot, bind it to the -// agent instance identity, enable the Teams channel, and point its messaging -// endpoint at the agent. Teams app packaging and install live on the M365/Graph -// plane and stay out of azd; postdeploy prints a guide for those manual steps. +// It provisions ONLY the Azure resource plane: create the Azure Bot, bind it to +// the agent instance identity, enable the bot's Microsoft Teams *channel*, and +// point the bot's messaging endpoint at the agent. That "Teams channel" is an +// Azure Bot Service resource toggle — NOT a Teams app. Packaging and sideloading +// the Teams *app* live on the M365/Graph plane, stay out of azd, and are left to +// the user; postdeploy writes TEAMS_APP_SETUP.md with those manual steps. func ensureActivityBot( ctx context.Context, azdClient *azdext.AzdClient, @@ -156,6 +160,16 @@ func writeTeamsSetupGuide( return guidePath } +//go:embed assets/teams_app_setup_guide.md +var teamsSetupGuideMarkdown string + +// teamsSetupGuideTmpl is the compiled onboarding guide. Keeping the markdown in +// an actual .md file (assets/teams_app_setup_guide.md) lets editors render and +// lint it and catch formatting errors that a Go string literal hides. +var teamsSetupGuideTmpl = template.Must( + template.New("teamsSetupGuide").Parse(teamsSetupGuideMarkdown), +) + // teamsSetupGuideContent renders the Teams onboarding guide markdown. It gives // concrete, minimal step-by-step instructions for the two manual actions // (package the Teams app, then sideload it) and links to the official docs for @@ -163,106 +177,15 @@ func writeTeamsSetupGuide( // app manifest's bots[].botId MUST equal this bot's msaAppId, which azd bound to // the agent instance identity. func teamsSetupGuideContent(agentName, botName, msaAppID string) string { - return fmt.Sprintf(`# Connect %[1]s to Microsoft Teams - -`+"`azd deploy`"+` already did the Azure side for you: - -- Azure Bot: `+"`%[2]s`"+` (Microsoft Teams channel enabled) -- Bot ID (msaAppId): `+"`%[3]s`"+` <- you will paste this as the bot id - -Two manual steps remain: (A) create a Teams app package, then (B) upload it. -They are the same for any activity-protocol agent. - -## A. Create the Teams app package - -Pick ONE of the two ways below. - -### Easiest — Teams Developer Portal (no files by hand) - -1. Open https://dev.teams.microsoft.com/apps and select **+ New app**; enter a name. -2. Fill **Basic information** (short/long description, developer name and URLs). -3. Left menu **App features** -> **Bot** -> **Select an existing bot** -> enter the - Bot ID `+"`%[3]s`"+`, tick the **Personal** scope, then **Save**. -4. **Publish** -> **Download the app package** — this gives you a ready-to-upload .zip. - -Developer Portal guide: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/teams-developer-portal - -### Or by hand — build the .zip yourself - -Put these three files in a folder and zip them at the **root** (not inside a subfolder): - -- `+"`manifest.json`"+` (below) -- `+"`color.png`"+` — 192x192 px -- `+"`outline.png`"+` — 32x32 px, transparent background - -`+"```json"+` -{ - "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.19/MicrosoftTeams.schema.json", - "manifestVersion": "1.19", - "version": "1.0.0", - "id": "REPLACE-WITH-A-NEW-GUID", - "developer": { - "name": "Your Company", - "websiteUrl": "https://example.com", - "privacyUrl": "https://example.com/privacy", - "termsOfUseUrl": "https://example.com/terms" - }, - "name": { "short": "%[1]s", "full": "%[1]s" }, - "description": { "short": "%[1]s agent", "full": "%[1]s agent on Microsoft Teams" }, - "icons": { "color": "color.png", "outline": "outline.png" }, - "accentColor": "#FFFFFF", - "bots": [{ "botId": "%[3]s", "scopes": ["personal"] }] -} -`+"```"+` - -Note: `+"`id`"+` is a NEW GUID for the app itself (generate one) — it is NOT the Bot ID. -Only `+"`bots[].botId`"+` uses the Bot ID above. - -- Package + icon requirements: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/apps-package -- Manifest schema reference: https://learn.microsoft.com/microsoftteams/platform/resources/schema/manifest-schema -- Validate your .zip before uploading: https://dev.teams.microsoft.com/tools/store-validation - -## B. Upload (sideload) the app — just for yourself - -You do NOT need a Teams admin to try it yourself: - -1. In Teams, go to **Apps** -> **Manage your apps** -> **Upload an app**. -2. Select **Upload a custom app**, choose your .zip, then **Add**. -3. Select **Open**, then send a message to talk to your agent. - -Upload a custom app guide: https://learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload - -If **Upload a custom app** is missing or greyed out, custom app upload is turned off for -your tenant, or you want everyone in your org to get it from the org app catalog. Both need -a Teams admin: https://learn.microsoft.com/microsoftteams/platform/concepts/build-and-test/prepare-your-o365-tenant - -## C. Optional — do both from the command line - -Steps A and B can be scripted. This is a convenience path for repeat runs; it needs extra -tooling and does NOT bypass the tenant custom-app-upload setting above. - -Package: put the manifest.json from section A (its Bot ID is already filled in) next to your -two icons, then zip the three files at the root: - -`+"```"+`sh -zip -j %[1]s-teams-app.zip manifest.json color.png outline.png # bash -`+"```"+` -`+"```"+`powershell -Compress-Archive manifest.json,color.png,outline.png %[1]s-teams-app.zip # PowerShell -`+"```"+` - -Sideload for yourself with the Microsoft 365 Agents Toolkit CLI (atk). `+"`--scope Personal`"+` is a -per-user install and needs NO Teams admin: - -`+"```"+`sh -npm install -g @microsoft/m365agentstoolkit-cli # one-time; requires Node.js -atk auth login # sign in with your M365 account -atk install --file-path %[1]s-teams-app.zip --scope Personal -`+"```"+` - -atk prints a TitleId and a Teams deep link you can open to launch the agent. -atk CLI reference: https://learn.microsoft.com/microsoftteams/platform/toolkit/microsoft-365-agents-toolkit-cli -`, agentName, botName, msaAppID) + var buf bytes.Buffer + // Inputs are azd-controlled resource names and the template is compile-time + // embedded, so execution cannot realistically fail. + _ = teamsSetupGuideTmpl.Execute(&buf, struct { + AgentName string + BotName string + MsaAppID string + }{AgentName: agentName, BotName: botName, MsaAppID: msaAppID}) + return buf.String() } // printTeamsNextSteps prints a short pointer to the generated setup guide. The diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index e66efba571b..4c4132e107b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -14,15 +14,28 @@ import ( type AgentProtocol string const ( - AgentProtocolActivityProtocol AgentProtocol = "activity_protocol" - AgentProtocolInvocations AgentProtocol = "invocations" - AgentProtocolInvocationsWS AgentProtocol = "invocations_ws" - AgentProtocolResponses AgentProtocol = "responses" - AgentProtocolA2A AgentProtocol = "a2a" + // AgentProtocolActivityProtocol is the canonical Activity protocol wire value. + // Upstream renamed it from "activity_protocol" to "activity"; the legacy value + // is still accepted on read via AgentProtocolActivityProtocolLegacy. + AgentProtocolActivityProtocol AgentProtocol = "activity" + // AgentProtocolActivityProtocolLegacy is the pre-rename Activity wire value, + // preserved for back-compat when reading manifests authored before the rename. + AgentProtocolActivityProtocolLegacy AgentProtocol = "activity_protocol" + AgentProtocolInvocations AgentProtocol = "invocations" + AgentProtocolInvocationsWS AgentProtocol = "invocations_ws" + AgentProtocolResponses AgentProtocol = "responses" + AgentProtocolA2A AgentProtocol = "a2a" ) +// IsActivityProtocolName reports whether the given definition-level protocol name +// denotes the Activity protocol. It accepts both the canonical "activity" and the +// legacy "activity_protocol" value (upstream renamed it; legacy stays accepted). +func IsActivityProtocolName(p AgentProtocol) bool { + return p == AgentProtocolActivityProtocol || p == AgentProtocolActivityProtocolLegacy +} + // InvocableProtocols returns the set of protocols that azd can invoke directly. -// A2A and activity_protocol are deployment-only — they cannot be used for local +// A2A and activity are deployment-only — they cannot be used for local // or remote invocation through azd. func InvocableProtocols() []AgentProtocol { return []AgentProtocol{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go index 320c7d24b06..5e012d7eb72 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go @@ -2,7 +2,7 @@ // Licensed under the MIT License. // Package botservice provisions and tears down the Azure Bot and Microsoft Teams -// channel that front an activity-protocol (Teams) agent. It ports the Azure +// channel that front an activity agent. It ports the Azure // resource-plane steps of setup-instance-bot.ps1 (create bot, enable Teams // channel, set messaging endpoint) into the native azd deploy flow. Teams app // packaging and install stay out of scope — they live on the M365/Graph plane. @@ -170,6 +170,12 @@ func (cfg BotConfig) displayName() string { // to MsaAppID and ensures the Microsoft Teams channel is enabled. The bot Create // call is a PUT (create-or-update), so re-running after every deploy is a no-op // when nothing changed and refreshes the messaging endpoint when it did. +// +// This is a required Azure connector, not an optional extra: the Bot Service +// resource and its Teams channel are what let the agent receive Activity traffic, +// and the bot is bound to the agent identity via MsaAppID (the Bot Service token +// is validated against that identity). It is the resource-plane Teams *channel* +// only — the Teams *app* (M365 packaging/sideload) is a separate manual step. func (c *Client) EnsureBot(ctx context.Context, cfg BotConfig) error { if err := cfg.validate(); err != nil { return err @@ -195,6 +201,10 @@ func (c *Client) EnsureBot(ctx context.Context, cfg BotConfig) error { return c.ensureTeamsChannel(ctx, cfg.ResourceGroup, cfg.BotName) } +// ensureTeamsChannel enables the Microsoft Teams channel on the Azure Bot. This +// is the resource-plane channel toggle (an armbotservice MsTeamsChannel), which +// is required for the agent to exchange Activity messages over Teams. It does NOT +// create or publish a Teams *app*; sideloading that app stays a manual M365 step. func (c *Client) ensureTeamsChannel(ctx context.Context, resourceGroup, botName string) error { channel := armbotservice.BotChannel{ Location: new(botLocation), diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go index 07b72038ae5..da45b39c024 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go @@ -37,11 +37,11 @@ type ActivityProfile struct { } // IsActivityProtocol reports whether a hosted agent definition opts into the -// Activity protocol, either through a container-level activity_protocol entry or +// Activity protocol, either through a container-level activity entry or // an agent_endpoint that advertises the friendly "activity" protocol. func IsActivityProtocol(ca agent_yaml.ContainerAgent) bool { for _, p := range ca.Protocols { - if agent_api.AgentProtocol(strings.TrimSpace(p.Protocol)) == agent_api.AgentProtocolActivityProtocol { + if agent_api.IsActivityProtocolName(agent_api.AgentProtocol(strings.TrimSpace(p.Protocol))) { return true } } @@ -65,12 +65,12 @@ func ResolveActivityProfile(ca agent_yaml.ContainerAgent) ActivityProfile { return ActivityProfile{IsActivity: true, UseCase: ActivityUseCaseSimple} } -// ActivityAgentEndpoint returns the agent_endpoint declaration an -// activity-protocol (Teams) agent requires: the friendly "activity" protocol -// guarded by the BotServiceRbac authorization scheme. `azd init` uses it to make -// an activity agent scaffolded from local code carry the exact same declaration -// as one initialized from a manifest, so the generated azure.yaml is identical -// and `azd deploy` provisions the Teams bot. Phase 1 covers the simple use case. +// ActivityAgentEndpoint returns the agent_endpoint declaration an activity agent +// requires: the friendly "activity" protocol guarded by the BotServiceRbac +// authorization scheme. `azd init` uses it to make an activity agent scaffolded +// from local code carry the exact same declaration as one initialized from a +// manifest, so the generated azure.yaml is identical and `azd deploy` provisions +// the Azure Bot connector. Phase 1 covers the simple use case. func ActivityAgentEndpoint() *agent_yaml.AgentEndpoint { return &agent_yaml.AgentEndpoint{ Protocols: []string{string(agent_api.AgentEndpointProtocolActivity)}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go index a235db3de87..ad1c98a67e1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -19,10 +19,10 @@ func TestIsActivityProtocol(t *testing.T) { want bool }{ { - name: "container activity_protocol", + name: "container activity", ca: agent_yaml.ContainerAgent{ Protocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity_protocol", Version: "v1"}, + {Protocol: "activity", Version: "1.0.0"}, }, }, want: true, @@ -37,7 +37,7 @@ func TestIsActivityProtocol(t *testing.T) { want: true, }, { - name: "activity_protocol with surrounding whitespace", + name: "legacy activity_protocol with surrounding whitespace", ca: agent_yaml.ContainerAgent{ Protocols: []agent_yaml.ProtocolVersionRecord{ {Protocol: " activity_protocol ", Version: "v1"}, @@ -77,7 +77,7 @@ func TestResolveActivityProfile(t *testing.T) { t.Run("activity agent resolves simple", func(t *testing.T) { ca := agent_yaml.ContainerAgent{ Protocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity_protocol", Version: "v1"}, + {Protocol: "activity", Version: "1.0.0"}, }, } got := ResolveActivityProfile(ca) 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 3a665c44c5a..04c308ab85a 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 @@ -623,7 +623,8 @@ func TestDisplayableProtocolFor(t *testing.T) { wantURLScheme: "wss", wantURLOmitAgent: true, }, - {name: "activity_protocol excluded", protocol: "activity_protocol", wantNil: true}, + {name: "activity excluded", protocol: "activity", wantNil: true}, + {name: "legacy activity_protocol excluded", protocol: "activity_protocol", wantNil: true}, {name: "unknown excluded", protocol: "unknown_proto", wantNil: true}, } From 536335d8cbe83a9322a208441c1ab276f755af2e Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:01:59 +0800 Subject: [PATCH 13/17] fix(ai.agents): decouple activity bot provisioning from optimization-reporting guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The postdeploy Teams bot (a required connector for activity agents) ran only after early-return guards written for best-effort optimization reporting, so a missing FOUNDRY_PROJECT_ENDPOINT / AZURE_TENANT_ID / credential silently skipped the bot while 'azd deploy' reported success — inconsistent with the EnsureBot error path that hard-fails. Resolve the activity profile first, gather the shared inputs once (no skip-vs-fail policy in the read), then let each step apply its own policy: the required bot hard-fails on a missing input; best-effort optimization reporting logs and skips. Non-activity agents are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/listen.go | 136 +++++++++--------- 1 file changed, 72 insertions(+), 64 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 49c76f08a9a..cbfadedd5f8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -286,93 +286,101 @@ func warnDuplicateAgentNames(proj *azdext.ProjectConfig) { } } -func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ServiceEventArgs) error { - svc := args.Service - - // Skip when the service is not a hosted agent. - if !isHostedAgentService(svc, args.Project) { - return nil - } - - // Set up the project endpoint and credential used by optimization reporting. - // This path now feeds only best-effort optimization telemetry (the client-side - // agent-identity RBAC assignment was removed), so any setup failure is logged as - // a warning and skipped rather than failing an otherwise-successful deploy. +// gatherPostdeployInputs reads the environment inputs shared by the two postdeploy +// steps (activity-bot provisioning and optimization reporting): the current +// environment name, the Foundry project endpoint, the tenant, and a credential +// built from that tenant. It only gathers and returns the first error encountered; +// it does NOT decide skip-vs-fail, so each caller can apply its own policy to the +// returned error (the required Teams bot fails; best-effort reporting skips). +func gatherPostdeployInputs( + ctx context.Context, azdClient *azdext.AzdClient, +) (envName, endpoint, tenant string, cred *azidentity.AzureDeveloperCLICredential, err error) { envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) if err != nil { - log.Printf("postdeploy: skipping optimization reporting for %s: "+ - "failed to get current environment: %v", svc.Name, err) - return nil + return "", "", "", nil, fmt.Errorf("failed to get current environment: %w", err) } + envName = envResp.Environment.Name - envName := envResp.Environment.Name - - // Read the project endpoint for API calls. - endpointResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: envName, - Key: "FOUNDRY_PROJECT_ENDPOINT", - }) - if err != nil { - log.Printf("postdeploy: skipping optimization reporting for %s: "+ - "failed to read FOUNDRY_PROJECT_ENDPOINT: %v", svc.Name, err) - return nil + if endpoint, err = readEnvValue(ctx, azdClient, envName, "FOUNDRY_PROJECT_ENDPOINT"); err != nil { + return envName, "", "", nil, err } - if endpointResp.Value == "" { - log.Printf("postdeploy: skipping optimization reporting for %s: "+ - "FOUNDRY_PROJECT_ENDPOINT is not set in the environment", svc.Name) - return nil + if tenant, err = readEnvValue(ctx, azdClient, envName, "AZURE_TENANT_ID"); err != nil { + return envName, endpoint, "", nil, err } - // Create a credential for API calls. - tenantResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: envName, - Key: "AZURE_TENANT_ID", - }) - if err != nil { - log.Printf("postdeploy: skipping optimization reporting for %s: "+ - "failed to read AZURE_TENANT_ID: %v", svc.Name, err) - return nil - } - if tenantResp.Value == "" { - log.Printf("postdeploy: skipping optimization reporting for %s: "+ - "AZURE_TENANT_ID is not set in the environment", svc.Name) - return nil - } - - cred, err := azidentity.NewAzureDeveloperCLICredential( + cred, err = azidentity.NewAzureDeveloperCLICredential( &azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantResp.Value, + TenantID: tenant, AdditionallyAllowedTenants: []string{"*"}, }, ) if err != nil { - log.Printf("postdeploy: skipping optimization reporting for %s: "+ - "failed to create credential: %v", svc.Name, err) + return envName, endpoint, tenant, nil, fmt.Errorf("failed to create credential: %w", err) + } + return envName, endpoint, tenant, cred, nil +} + +func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ServiceEventArgs) error { + svc := args.Service + + // Skip when the service is not a hosted agent. + if !isHostedAgentService(svc, args.Project) { return nil } - // For activity agents, provision the Azure Bot + Teams - // channel bound to the agent instance identity. No-op for other agents. - if err := ensureActivityBot( - ctx, azdClient, cred, envName, svc, args.Project, - endpointResp.Value, tenantResp.Value, - ); err != nil { - return fmt.Errorf( - "agent %q deployed successfully, but configuring its Microsoft Teams bot failed: %w\n"+ - " The agent version is active — only the Teams channel binding is missing "+ - "(commonly Azure Bot permissions or quota). Resolve the cause and re-run 'azd deploy'.", - svc.Name, err, - ) + // Whether this service is an activity agent decides how the shared inputs below + // are treated: an activity agent requires the Teams bot connector, so a missing + // input must fail the deploy; every other agent only feeds best-effort + // optimization reporting, which is safe to skip. + isActivity := false + if ca, isHosted, _, defErr := project.LoadAgentDefinition(svc, args.Project.Path); defErr == nil && isHosted { + isActivity = project.ResolveActivityProfile(ca).IsActivity + } + + // Read the inputs both steps draw from once. Gathering does not decide + // skip-vs-fail; each step below applies its own policy to inputErr, so the + // required Teams bot never inherits optimization reporting's "log and skip" + // preconditions and vice versa. + envName, endpoint, tenant, cred, inputErr := gatherPostdeployInputs(ctx, azdClient) + + // Step 1 — activity bot: a required connector, provisioned and validated on its + // own terms. A missing prerequisite fails the deploy rather than being skipped, + // consistent with the EnsureBot failure handled just below. No-op for other agents. + if isActivity { + if inputErr != nil { + return fmt.Errorf( + "agent %q deployed successfully, but its required Microsoft Teams bot could not be "+ + "configured: %w\n"+ + " Ensure the agent is provisioned in this environment, then re-run 'azd deploy'.", + svc.Name, inputErr, + ) + } + if err := ensureActivityBot( + ctx, azdClient, cred, envName, svc, args.Project, endpoint, tenant, + ); err != nil { + return fmt.Errorf( + "agent %q deployed successfully, but configuring its Microsoft Teams bot failed: %w\n"+ + " The agent version is active — only the Teams channel binding is missing "+ + "(commonly Azure Bot permissions or quota). Resolve the cause and re-run 'azd deploy'.", + svc.Name, err, + ) + } } - // Report optimization candidate deployment (best-effort: panics are logged, not propagated). + // Step 2 — optimization reporting: best-effort, skipped on its own terms. A + // missing input only logs and skips; it never fails an otherwise-successful + // deploy (the client-side agent-identity RBAC assignment was removed). + if inputErr != nil { + log.Printf("postdeploy: skipping optimization reporting for %s: %v", svc.Name, inputErr) + return nil + } func() { defer func() { if r := recover(); r != nil { log.Printf("postdeploy: optimization reporting panicked for %s: %v", svc.Name, r) } }() - reportSvcOptimizationDeployment(ctx, azdClient, svc, envName, endpointResp.Value, + reportSvcOptimizationDeployment(ctx, azdClient, svc, envName, endpoint, func(endpoint string) *optimize_api.OptimizeClient { return optimize_api.NewOptimizeClient(endpoint, cred) }, From aedd74f2160f4cb6b083551aad76a62be9b9cd4d Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:38:21 +0800 Subject: [PATCH 14/17] Allow Activity protocol to coexist with other protocols Activity is not exclusive: the platform models every protocol as a sibling per-protocol entry on the same endpoint, so agent.yaml just needs to advertise each selected protocol plus the BotServiceRbac scheme Activity requires. - init now composes the activity endpoint (advertises all selected protocols + appends BotServiceRbac) instead of overwriting agent_endpoint wholesale - remove validateProtocolSelection's activity-exclusive rejection - non-activity path unchanged (agent_endpoint stays nil) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_from_code.go | 48 +++------------- .../internal/cmd/init_from_code_test.go | 10 ++-- .../internal/project/activity_profile.go | 56 +++++++++++++++++++ .../internal/project/activity_profile_test.go | 52 +++++++++++++++++ 4 files changed, 123 insertions(+), 43 deletions(-) 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 7ba5f09e952..872898330e8 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 @@ -6,7 +6,6 @@ package cmd import ( "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" - "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "context" @@ -547,12 +546,16 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) } // An activity agent additionally advertises the friendly "activity" endpoint - // guarded by BotServiceRbac. Injecting it here mirrors the manifest-based init - // so the generated azure.yaml is identical and `azd deploy` provisions the - // Azure Bot connector. Phase 1 covers the simple use case; digital-worker is a - // Phase 2 addition. No-op for non-activity agents. + // guarded by BotServiceRbac. We compose this into any existing agent_endpoint + // rather than overwriting it, so Activity can coexist with the other + // protocols the agent selected (responses/invocations). Injecting it here + // mirrors the manifest-based init so the generated azure.yaml is identical and + // `azd deploy` provisions the Azure Bot connector. Phase 1 covers the simple + // use case; digital-worker is a Phase 2 addition. No-op for non-activity agents. if project.IsActivityProtocol(*definition) { - definition.AgentEndpoint = project.ActivityAgentEndpoint() + definition.AgentEndpoint = project.ComposeActivityAgentEndpoint( + definition.AgentEndpoint, definition.Protocols, + ) } // Add model resource if a model was selected @@ -950,9 +953,6 @@ func promptProtocols( Version: version, }) } - if err := validateProtocolSelection(records); err != nil { - return nil, err - } return records, nil } @@ -1013,39 +1013,9 @@ func promptProtocols( ) } - if err := validateProtocolSelection(records); err != nil { - return nil, err - } - return records, nil } -// validateProtocolSelection rejects selecting activity alongside any other -// protocol. When an agent speaks the Activity protocol, init injects a single -// agent-level endpoint (ActivityAgentEndpoint — the "activity" endpoint guarded -// by BotServiceRbac) that overrides AgentEndpoint wholesale. That injection only -// models the activity endpoint and can't compose with the endpoints other -// protocols (responses/invocations) require, so combining them would emit an -// azure.yaml that advertises those protocols but wires up only the activity -// endpoint. This is not a platform limitation — the platform allows coexistence; -// it is a Phase-1 scoping choice. -func validateProtocolSelection(records []agent_yaml.ProtocolVersionRecord) error { - if len(records) < 2 { - return nil - } - for _, r := range records { - if agent_api.IsActivityProtocolName(agent_api.AgentProtocol(r.Protocol)) { - return exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - "activity cannot be combined with other protocols", - "An activity agent exposes only the activity endpoint; "+ - "select activity on its own.", - ) - } - } - return nil -} - // knownProtocolNames returns a comma-separated list of known protocol names. func knownProtocolNames() string { names := make([]string, 0, len(knownProtocols)) 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 f351637bc6e..83caa1d14b3 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 @@ -627,10 +627,12 @@ func TestPromptProtocols_FlagValues(t *testing.T) { }, }, { - name: "activity cannot be combined", - flagProtocols: []string{"activity", "responses"}, - wantErr: true, - wantErrContain: "cannot be combined", + name: "activity coexists with other protocols", + flagProtocols: []string{"activity", "responses"}, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "responses", Version: "2.0.0"}, + }, }, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go index da45b39c024..f55748c6046 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go @@ -79,3 +79,59 @@ func ActivityAgentEndpoint() *agent_yaml.AgentEndpoint { }, } } + +// ComposeActivityAgentEndpoint folds the Activity endpoint requirements into an +// agent's agent_endpoint declaration instead of overwriting it, so the Activity +// protocol can coexist with the other protocols the agent speaks +// (responses/invocations/...). Activity is not exclusive: the platform models +// every protocol as a sibling per-protocol entry on the same endpoint, and the +// endpoint carries a list of protocols and a list of authorization schemes. This +// helper therefore (1) advertises every selected protocol on the endpoint, +// normalizing the legacy "activity_protocol" spelling to the canonical +// "activity", and (2) ensures the BotServiceRbac scheme Activity requires is +// present without dropping any scheme already declared. For a pure-activity +// agent the result is identical to ActivityAgentEndpoint(): protocols=["activity"] +// guarded by BotServiceRbac. No-op inputs (nil existing endpoint) start fresh. +func ComposeActivityAgentEndpoint( + existing *agent_yaml.AgentEndpoint, + protocols []agent_yaml.ProtocolVersionRecord, +) *agent_yaml.AgentEndpoint { + ep := existing + if ep == nil { + ep = &agent_yaml.AgentEndpoint{} + } + + // Advertise every selected protocol on the endpoint (dedup, preserve order), + // normalizing activity_protocol -> activity so the endpoint carries the + // canonical wire value. + seen := make(map[string]bool, len(ep.Protocols)) + for _, p := range ep.Protocols { + seen[strings.TrimSpace(p)] = true + } + for _, p := range protocols { + name := strings.TrimSpace(p.Protocol) + if name == "" { + continue + } + if agent_api.IsActivityProtocolName(agent_api.AgentProtocol(name)) { + name = string(agent_api.AgentEndpointProtocolActivity) + } + if seen[name] { + continue + } + ep.Protocols = append(ep.Protocols, name) + seen[name] = true + } + + // Ensure the BotServiceRbac scheme Activity requires is present, keeping any + // scheme already declared for the other protocols. + for _, s := range ep.AuthorizationSchemes { + if s.Type == string(agent_api.AgentEndpointAuthSchemeBotServiceRbac) { + return ep + } + } + ep.AuthorizationSchemes = append(ep.AuthorizationSchemes, agent_yaml.AuthorizationScheme{ + Type: string(agent_api.AgentEndpointAuthSchemeBotServiceRbac), + }) + return ep +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go index ad1c98a67e1..4c28d637a71 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -174,3 +174,55 @@ func TestActivityAgentEndpoint(t *testing.T) { require.True(t, IsActivityProtocol(ca)) require.Equal(t, ActivityUseCaseSimple, ResolveActivityProfile(ca).UseCase) } + +// TestComposeActivityAgentEndpoint verifies that Activity requirements are folded +// into an agent's agent_endpoint instead of overwriting it, so Activity can +// coexist with other protocols. A pure-activity agent must still produce the +// exact ActivityAgentEndpoint() shape. +func TestComposeActivityAgentEndpoint(t *testing.T) { + t.Run("pure activity matches ActivityAgentEndpoint", func(t *testing.T) { + ep := ComposeActivityAgentEndpoint(nil, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity", Version: "1.0.0"}, + }) + require.Equal(t, []string{"activity"}, ep.Protocols) + require.Len(t, ep.AuthorizationSchemes, 1) + require.Equal(t, "BotServiceRbac", ep.AuthorizationSchemes[0].Type) + }) + + t.Run("activity coexists with responses and invocations", func(t *testing.T) { + ep := ComposeActivityAgentEndpoint(nil, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + {Protocol: "invocations", Version: "1.0.0"}, + {Protocol: "activity", Version: "1.0.0"}, + }) + require.Equal(t, []string{"responses", "invocations", "activity"}, ep.Protocols) + require.Len(t, ep.AuthorizationSchemes, 1) + require.Equal(t, "BotServiceRbac", ep.AuthorizationSchemes[0].Type) + }) + + t.Run("legacy activity_protocol is normalized to activity", func(t *testing.T) { + ep := ComposeActivityAgentEndpoint(nil, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + {Protocol: "activity_protocol", Version: "v1"}, + }) + require.Equal(t, []string{"responses", "activity"}, ep.Protocols) + }) + + t.Run("existing schemes are preserved and rbac is not duplicated", func(t *testing.T) { + existing := &agent_yaml.AgentEndpoint{ + Protocols: []string{"responses"}, + AuthorizationSchemes: []agent_yaml.AuthorizationScheme{ + {Type: "EntraId"}, + {Type: "BotServiceRbac"}, + }, + } + ep := ComposeActivityAgentEndpoint(existing, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + {Protocol: "activity", Version: "1.0.0"}, + }) + require.Equal(t, []string{"responses", "activity"}, ep.Protocols) + require.Len(t, ep.AuthorizationSchemes, 2) + require.Equal(t, "EntraId", ep.AuthorizationSchemes[0].Type) + require.Equal(t, "BotServiceRbac", ep.AuthorizationSchemes[1].Type) + }) +} From c140656b5d3da874b456258b8303ed77b551b8e7 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:46:28 +0800 Subject: [PATCH 15/17] Add end-to-end coexistence regression for both init paths Drives the real init-from-code assembly (compose + schema validation) and init-from-manifest parse to prove Activity coexists with responses/invocations, while the non-activity path stays unchanged (no agent_endpoint injected). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../activity_coexist_regression_test.go | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go new file mode 100644 index 00000000000..8f4e22602b5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/braydonk/yaml" + "github.com/stretchr/testify/require" +) + +// TestActivityCoexistenceRegression is an end-to-end, offline regression for the +// two ways an agent definition is produced — `azd ai agent init` from local code +// and from a manifest — after Activity was allowed to coexist with other +// protocols. It drives the real production helpers (IsActivityProtocol, +// ComposeActivityAgentEndpoint) and the real schema validation +// (agent_yaml.ValidateAgentDefinition) so a regression in either path is caught +// without needing Azure. Live Teams/bot provisioning is validated separately. +func TestActivityCoexistenceRegression(t *testing.T) { + // assembleFromCode mirrors init_from_code.go: build the container agent from + // the selected protocols, then compose the activity endpoint (no-op for + // non-activity agents). It returns the definition and the marshalled + // agent.yaml init would validate/write. + assembleFromCode := func(t *testing.T, protocols []agent_yaml.ProtocolVersionRecord) (agent_yaml.ContainerAgent, []byte) { + t.Helper() + def := agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "echo", + Kind: agent_yaml.AgentKindHosted, + }, + Protocols: protocols, + CodeConfiguration: &agent_yaml.CodeConfiguration{ + Runtime: "python_3_13", + EntryPoint: "app.py", + }, + } + if IsActivityProtocol(def) { + def.AgentEndpoint = ComposeActivityAgentEndpoint(def.AgentEndpoint, def.Protocols) + } + out, err := yaml.Marshal(def) + require.NoError(t, err) + // The definition init produces must pass the same schema validation the + // on-disk agent.yaml path enforces. + require.NoError(t, agent_yaml.ValidateAgentDefinition(out)) + return def, out + } + + t.Run("init-from-code", func(t *testing.T) { + t.Run("responses only is unchanged (no endpoint injected)", func(t *testing.T) { + def, _ := assembleFromCode(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }) + require.False(t, IsActivityProtocol(def)) + require.Nil(t, def.AgentEndpoint, "non-activity agents must not carry an agent_endpoint") + }) + + t.Run("activity only keeps the single-protocol bot endpoint", func(t *testing.T) { + def, _ := assembleFromCode(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity", Version: "1.0.0"}, + }) + require.True(t, IsActivityProtocol(def)) + require.NotNil(t, def.AgentEndpoint) + require.Equal(t, []string{"activity"}, def.AgentEndpoint.Protocols) + requireHasScheme(t, def.AgentEndpoint, "BotServiceRbac") + }) + + t.Run("activity coexists with responses and invocations", func(t *testing.T) { + def, _ := assembleFromCode(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + {Protocol: "invocations", Version: "1.0.0"}, + {Protocol: "activity", Version: "1.0.0"}, + }) + require.True(t, IsActivityProtocol(def)) + require.NotNil(t, def.AgentEndpoint) + // Every selected protocol is advertised on the endpoint, and the + // bot scheme Activity requires is present alongside them. + require.Equal(t, []string{"responses", "invocations", "activity"}, def.AgentEndpoint.Protocols) + requireHasScheme(t, def.AgentEndpoint, "BotServiceRbac") + // The container still declares all three protocols with versions. + require.Len(t, def.Protocols, 3) + }) + }) + + t.Run("init-from-manifest", func(t *testing.T) { + // A manifest-authored coexistence definition is passed through verbatim: + // azd imposes no activity-exclusive restriction on the manifest path. + manifest := []byte(` +name: echo +template: + kind: hosted + name: echo + image: myregistry.azurecr.io/echo:v1 + protocols: + - protocol: responses + version: 2.0.0 + - protocol: activity + version: 1.0.0 + agent_endpoint: + protocols: + - responses + - activity + authorization_schemes: + - type: Entra + isolation_key_source: + kind: Header + - type: BotServiceRbac +`) + agent, err := agent_yaml.ExtractAgentDefinition(manifest) + require.NoError(t, err) + ca, ok := agent.(agent_yaml.ContainerAgent) + require.True(t, ok) + + require.True(t, IsActivityProtocol(ca)) + require.Equal(t, ActivityUseCaseSimple, ResolveActivityProfile(ca).UseCase) + require.Len(t, ca.Protocols, 2) + require.NotNil(t, ca.AgentEndpoint) + require.Equal(t, []string{"responses", "activity"}, ca.AgentEndpoint.Protocols) + requireHasScheme(t, ca.AgentEndpoint, "Entra") + requireHasScheme(t, ca.AgentEndpoint, "BotServiceRbac") + }) +} + +func requireHasScheme(t *testing.T, ep *agent_yaml.AgentEndpoint, schemeType string) { + t.Helper() + for _, s := range ep.AuthorizationSchemes { + if s.Type == schemeType { + return + } + } + t.Fatalf("expected authorization scheme %q, got %+v", schemeType, ep.AuthorizationSchemes) +} From d026d68fc7e035fa55e0a2ad71cfec85a4e14d9c Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:13:31 +0800 Subject: [PATCH 16/17] refactor(ai.agents): move test-only ActivityAgentEndpoint into test fixture ActivityAgentEndpoint() was only referenced from activity_profile_test.go; every production path goes through ComposeActivityAgentEndpoint. Move it into the test as expectedActivityEndpoint() to keep the project package surface minimal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/project/activity_profile.go | 19 ++-------------- .../internal/project/activity_profile_test.go | 22 +++++++++++++++---- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go index f55748c6046..7ef2fd059bc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go @@ -65,21 +65,6 @@ func ResolveActivityProfile(ca agent_yaml.ContainerAgent) ActivityProfile { return ActivityProfile{IsActivity: true, UseCase: ActivityUseCaseSimple} } -// ActivityAgentEndpoint returns the agent_endpoint declaration an activity agent -// requires: the friendly "activity" protocol guarded by the BotServiceRbac -// authorization scheme. `azd init` uses it to make an activity agent scaffolded -// from local code carry the exact same declaration as one initialized from a -// manifest, so the generated azure.yaml is identical and `azd deploy` provisions -// the Azure Bot connector. Phase 1 covers the simple use case. -func ActivityAgentEndpoint() *agent_yaml.AgentEndpoint { - return &agent_yaml.AgentEndpoint{ - Protocols: []string{string(agent_api.AgentEndpointProtocolActivity)}, - AuthorizationSchemes: []agent_yaml.AuthorizationScheme{ - {Type: string(agent_api.AgentEndpointAuthSchemeBotServiceRbac)}, - }, - } -} - // ComposeActivityAgentEndpoint folds the Activity endpoint requirements into an // agent's agent_endpoint declaration instead of overwriting it, so the Activity // protocol can coexist with the other protocols the agent speaks @@ -90,8 +75,8 @@ func ActivityAgentEndpoint() *agent_yaml.AgentEndpoint { // normalizing the legacy "activity_protocol" spelling to the canonical // "activity", and (2) ensures the BotServiceRbac scheme Activity requires is // present without dropping any scheme already declared. For a pure-activity -// agent the result is identical to ActivityAgentEndpoint(): protocols=["activity"] -// guarded by BotServiceRbac. No-op inputs (nil existing endpoint) start fresh. +// agent the result is protocols=["activity"] guarded by BotServiceRbac — the same +// declaration the manifest path emits. No-op inputs (nil existing endpoint) start fresh. func ComposeActivityAgentEndpoint( existing *agent_yaml.AgentEndpoint, protocols []agent_yaml.ProtocolVersionRecord, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go index 4c28d637a71..d17bf827f60 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -153,12 +153,26 @@ func TestActivityDeclarationSurvivesInitRoundTrip(t *testing.T) { require.Equal(t, "BotServiceRbac", got.AgentEndpoint.AuthorizationSchemes[0].Type) } +// expectedActivityEndpoint is the agent_endpoint shape a pure-activity agent must +// carry: the friendly "activity" protocol guarded by BotServiceRbac. It is the +// expected-shape oracle for these tests — it mirrors what ComposeActivityAgentEndpoint +// produces for an activity-only selection and what the manifest path emits, so the +// tests can assert both init paths converge on the same declaration. +func expectedActivityEndpoint() *agent_yaml.AgentEndpoint { + return &agent_yaml.AgentEndpoint{ + Protocols: []string{"activity"}, + AuthorizationSchemes: []agent_yaml.AuthorizationScheme{ + {Type: "BotServiceRbac"}, + }, + } +} + // TestActivityAgentEndpoint pins the endpoint declaration that `azd init` from // local code injects for an activity_protocol agent. It must match the // manifest-based shape (friendly "activity" protocol guarded by BotServiceRbac) // so the two init paths produce identical azure.yaml and both satisfy deploy. func TestActivityAgentEndpoint(t *testing.T) { - ep := ActivityAgentEndpoint() + ep := expectedActivityEndpoint() require.NotNil(t, ep) require.Equal(t, []string{"activity"}, ep.Protocols) require.Len(t, ep.AuthorizationSchemes, 1) @@ -169,7 +183,7 @@ func TestActivityAgentEndpoint(t *testing.T) { ca := agent_yaml.ContainerAgent{ AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "echo"}, Protocols: []agent_yaml.ProtocolVersionRecord{{Protocol: "activity_protocol", Version: "v1"}}, - AgentEndpoint: ActivityAgentEndpoint(), + AgentEndpoint: expectedActivityEndpoint(), } require.True(t, IsActivityProtocol(ca)) require.Equal(t, ActivityUseCaseSimple, ResolveActivityProfile(ca).UseCase) @@ -178,9 +192,9 @@ func TestActivityAgentEndpoint(t *testing.T) { // TestComposeActivityAgentEndpoint verifies that Activity requirements are folded // into an agent's agent_endpoint instead of overwriting it, so Activity can // coexist with other protocols. A pure-activity agent must still produce the -// exact ActivityAgentEndpoint() shape. +// exact expectedActivityEndpoint() shape. func TestComposeActivityAgentEndpoint(t *testing.T) { - t.Run("pure activity matches ActivityAgentEndpoint", func(t *testing.T) { + t.Run("pure activity matches expectedActivityEndpoint", func(t *testing.T) { ep := ComposeActivityAgentEndpoint(nil, []agent_yaml.ProtocolVersionRecord{ {Protocol: "activity", Version: "1.0.0"}, }) From 0334716291ecb29456d5cd2b26906ef39767ff8e Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:02:22 +0800 Subject: [PATCH 17/17] feat(ai.agents): default activity protocol to 2.0.0 2.0.0 is the service's official/recommended activity protocol version (1.0.0 is accepted but deprecated going forward). The version only selects the platform's internal Bot Service -> container route, so the client, Bot Service messaging endpoint, and agent sample are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_from_code.go | 12 +++++++----- .../internal/cmd/init_from_code_test.go | 4 ++-- .../project/activity_coexist_regression_test.go | 6 +++--- .../internal/project/activity_profile_test.go | 10 +++++----- 4 files changed, 17 insertions(+), 15 deletions(-) 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 872898330e8..7eb3264bd54 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 @@ -907,11 +907,13 @@ var knownProtocols = []protocolInfo{ {Name: "responses", Version: "2.0.0"}, {Name: "invocations", Version: "1.0.0"}, // "activity" is the canonical protocol name (legacy alias: "activity_protocol"). - // Version is a platform routing selector, not a free version string: "v1" and - // "1.0.0" route to /api/messages, "2.0.0" to /activity/messages. Our sample - // serves /api/messages, so "1.0.0" keeps that routing while matching the - // semver style of responses/invocations. - {Name: "activity", Version: "1.0.0"}, + // 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 -> + // container inside the platform: the client, the Bot Service messaging endpoint, + // and the agent sample are all unaffected by the choice. "2.0.0" is the service's + // official/recommended version ("1.0.0" is accepted but deprecated going forward), + // so new agents default to it, matching the latest-version convention responses uses. + {Name: "activity", Version: "2.0.0"}, } // promptProtocols asks the user which protocols their agent supports. 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 83caa1d14b3..675a67d72fd 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 @@ -623,14 +623,14 @@ func TestPromptProtocols_FlagValues(t *testing.T) { name: "activity only", flagProtocols: []string{"activity"}, wantProtocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }, }, { name: "activity coexists with other protocols", flagProtocols: []string{"activity", "responses"}, wantProtocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, {Protocol: "responses", Version: "2.0.0"}, }, }, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go index 8f4e22602b5..d2a97a785f0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_coexist_regression_test.go @@ -59,7 +59,7 @@ func TestActivityCoexistenceRegression(t *testing.T) { t.Run("activity only keeps the single-protocol bot endpoint", func(t *testing.T) { def, _ := assembleFromCode(t, []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }) require.True(t, IsActivityProtocol(def)) require.NotNil(t, def.AgentEndpoint) @@ -71,7 +71,7 @@ func TestActivityCoexistenceRegression(t *testing.T) { def, _ := assembleFromCode(t, []agent_yaml.ProtocolVersionRecord{ {Protocol: "responses", Version: "2.0.0"}, {Protocol: "invocations", Version: "1.0.0"}, - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }) require.True(t, IsActivityProtocol(def)) require.NotNil(t, def.AgentEndpoint) @@ -97,7 +97,7 @@ template: - protocol: responses version: 2.0.0 - protocol: activity - version: 1.0.0 + version: 2.0.0 agent_endpoint: protocols: - responses diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go index d17bf827f60..77cc8d0c2ca 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -22,7 +22,7 @@ func TestIsActivityProtocol(t *testing.T) { name: "container activity", ca: agent_yaml.ContainerAgent{ Protocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }, }, want: true, @@ -77,7 +77,7 @@ func TestResolveActivityProfile(t *testing.T) { t.Run("activity agent resolves simple", func(t *testing.T) { ca := agent_yaml.ContainerAgent{ Protocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }, } got := ResolveActivityProfile(ca) @@ -196,7 +196,7 @@ func TestActivityAgentEndpoint(t *testing.T) { func TestComposeActivityAgentEndpoint(t *testing.T) { t.Run("pure activity matches expectedActivityEndpoint", func(t *testing.T) { ep := ComposeActivityAgentEndpoint(nil, []agent_yaml.ProtocolVersionRecord{ - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }) require.Equal(t, []string{"activity"}, ep.Protocols) require.Len(t, ep.AuthorizationSchemes, 1) @@ -207,7 +207,7 @@ func TestComposeActivityAgentEndpoint(t *testing.T) { ep := ComposeActivityAgentEndpoint(nil, []agent_yaml.ProtocolVersionRecord{ {Protocol: "responses", Version: "2.0.0"}, {Protocol: "invocations", Version: "1.0.0"}, - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }) require.Equal(t, []string{"responses", "invocations", "activity"}, ep.Protocols) require.Len(t, ep.AuthorizationSchemes, 1) @@ -232,7 +232,7 @@ func TestComposeActivityAgentEndpoint(t *testing.T) { } ep := ComposeActivityAgentEndpoint(existing, []agent_yaml.ProtocolVersionRecord{ {Protocol: "responses", Version: "2.0.0"}, - {Protocol: "activity", Version: "1.0.0"}, + {Protocol: "activity", Version: "2.0.0"}, }) require.Equal(t, []string{"responses", "activity"}, ep.Protocols) require.Len(t, ep.AuthorizationSchemes, 2)