diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 8a52f71191b..84a77939c37 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -100,3 +100,14 @@ words: - testdir # Test infrastructure - recordproxy + # Activity protocol / Teams bot terms + - armbotservice + - botservice + - Azurebot + - idempotently + - sideload + - sideloading + - microsoftteams + - upsert + - m365agentstoolkit + - atk 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/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 b538dc8e7e7..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 @@ -545,6 +545,19 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) CodeConfiguration: codeConfig, } + // An activity agent additionally advertises the friendly "activity" endpoint + // 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.ComposeActivityAgentEndpoint( + definition.AgentEndpoint, definition.Protocols, + ) + } + // Add model resource if a model was selected if existingDeployment != nil { // Existing deployment: reference it by name only. Per REFERENCE.md an @@ -893,6 +906,12 @@ type protocolInfo struct { 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"}, } // 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 fc5afa774d1..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 @@ -619,6 +619,21 @@ func TestPromptProtocols_FlagValues(t *testing.T) { {Protocol: "invocations", Version: "1.0.0"}, }, }, + { + name: "activity only", + flagProtocols: []string{"activity"}, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity", Version: "1.0.0"}, + }, + }, + { + 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"}, + }, + }, } for _, tt := range tests { @@ -681,6 +696,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") { + t.Errorf("knownProtocolNames() = %q, want to contain 'activity'", result) + } } // fakePromptClient is a lightweight test double for azdext.PromptServiceClient. 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 3dc4c61368f..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,79 +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 } - // Report optimization candidate deployment (best-effort: panics are logged, not propagated). + // 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, + ) + } + } + + // 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) }, @@ -389,6 +411,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..cc4804168b5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "log" + "os" + "text/template" + + "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" + "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 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. +// +// 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, + cred azcore.TokenCredential, + envName string, + svc *azdext.ServiceConfig, + proj *azdext.ProjectConfig, + projectEndpoint string, + tenantID string, +) error { + ca, isHosted, _, err := project.LoadAgentDefinition(svc, proj.Path) + if err != nil || !isHosted { + return nil + } + + profile := project.ResolveActivityProfile(ca) + if !profile.IsActivity { + return nil + } + + // Only activity agents pay for the version lookup below; this keeps the base + // postdeploy path (slimmed on main) untouched for every other agent. + // + // 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. Fetch the active version to read that identity. + serviceKey := toServiceKey(svc.Name) + versionResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: fmt.Sprintf("AGENT_%s_VERSION", serviceKey), + }) + if err != nil { + return fmt.Errorf("failed to read AGENT_%s_VERSION for %q: %w", serviceKey, svc.Name, err) + } + if versionResp.Value == "" { + return fmt.Errorf( + "activity agent %q has no recorded version yet; cannot bind the Teams bot. "+ + "Re-run 'azd deploy' once the agent version is active.", + svc.Name, + ) + } + + agentClient := agent_api.NewAgentClient(projectEndpoint, cred) + versionObj, err := agentClient.GetAgentVersion( + ctx, svc.Name, versionResp.Value, DefaultAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf( + "failed to fetch agent version for %s/%s: %w", + svc.Name, versionResp.Value, err, + ) + } + + 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, botservice.BotScopeSalt(subscriptionID, resourceGroup)) + + 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 + } + + // 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 +} + +// 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)), 0o600); err != nil { + log.Printf("postdeploy: failed to write Teams setup guide %q: %v", guidePath, err) + return "" + } + 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 +// 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 { + 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 +// 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.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[].botId = 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 +// 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, 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 + } + fmt.Printf("Deleted Azure Bot %q for agent %q\n", botName, agentName) + } +} 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..86e53054e10 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go @@ -0,0 +1,65 @@ +// 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[].botId. + 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", + "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") + } +} + +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"), 0o750); 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") + } +} 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 572c95348f1..ed00129e0a5 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. -// activity_protocol is deployment-only — it cannot be used for local or remote +// activity is deployment-only — it cannot be used for local or remote // invocation through azd. A2A is remote-only (see IsInvocable / IsLocalInvocable). 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 new file mode 100644 index 00000000000..5e012d7eb72 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice.go @@ -0,0 +1,242 @@ +// 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 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" + "crypto/sha256" + "encoding/hex" + "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). 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 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 +// 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 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 +// 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. +// +// 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 + } + + bot := armbotservice.Bot{ + Location: new(botLocation), + Kind: to.Ptr(armbotservice.KindAzurebot), + SKU: &armbotservice.SKU{Name: to.Ptr(armbotservice.SKUNameF0)}, + Properties: &armbotservice.BotProperties{ + DisplayName: new(cfg.displayName()), + Endpoint: new(cfg.MessagingEndpoint), + MsaAppID: new(cfg.MsaAppID), + MsaAppType: to.Ptr(armbotservice.MsaAppTypeSingleTenant), + MsaAppTenantID: new(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) +} + +// 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), + Properties: &armbotservice.MsTeamsChannel{ + ChannelName: new(teamsChannelName), + Properties: &armbotservice.MsTeamsChannelProperties{ + IsEnabled: new(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 { + respErr, ok := errors.AsType[*azcore.ResponseError](err) + return ok && 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..8c9ec27e4d2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/botservice/botservice_test.go @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package botservice + +import ( + "context" + "errors" + "net/http" + "strings" + "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 TestEnsureBotIssuesUpsertOnEveryRun(t *testing.T) { + bots := &fakeBots{} + channels := &fakeChannels{} + c := &Client{bots: bots, channels: channels} + + for i := range 3 { + if err := c.EnsureBot(context.Background(), validConfig()); err != nil { + t.Fatalf("run %d: EnsureBot error: %v", i, err) + } + } + // 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)) + } +} + +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) { + 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) + } +} + +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_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) +} 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..7ef2fd059bc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile.go @@ -0,0 +1,122 @@ +// 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 +// 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. + 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 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.IsActivityProtocolName(agent_api.AgentProtocol(strings.TrimSpace(p.Protocol))) { + 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} +} + +// 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 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, +) *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 new file mode 100644 index 00000000000..d17bf827f60 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/activity_profile_test.go @@ -0,0 +1,242 @@ +// 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", + ca: agent_yaml.ContainerAgent{ + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity", Version: "1.0.0"}, + }, + }, + want: true, + }, + { + name: "endpoint activity protocol", + ca: agent_yaml.ContainerAgent{ + AgentEndpoint: &agent_yaml.AgentEndpoint{ + Protocols: []string{"activity"}, + }, + }, + want: true, + }, + { + name: "legacy 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", Version: "1.0.0"}, + }, + } + 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 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 +// 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) +} + +// 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 := expectedActivityEndpoint() + 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: expectedActivityEndpoint(), + } + 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 expectedActivityEndpoint() shape. +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"}, + }) + 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) + }) +} 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}, }