Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d0fa0b5
feat(ai.agents): native Azure Bot + Teams channel for activity-protoc…
Jul 2, 2026
4afe284
feat(ai.agents): support activity_protocol in init-from-code
Jul 2, 2026
9a01f03
fix(ai.agents): satisfy go-fix modernization and cspell CI
Jul 2, 2026
60c04d1
feat(ai.agents): write generic Teams app setup guide after activity d…
Jul 2, 2026
cb0a2b7
fix(ai.agents): tighten file/dir perms for gosec (G306/G301)
Jul 2, 2026
ef1808a
fix(ai.agents): globally-unique bot name + clearer postdeploy error
Jul 2, 2026
e2a76c6
docs(ai.agents): give concrete step-by-step Teams packaging + sideloa…
Jul 2, 2026
6cbb3bf
docs(ai.agents): make Teams sideload self-serve; tenant admin only as…
Jul 2, 2026
bd851b5
docs(ai.agents): add optional CLI path (zip package + atk sideload)
Jul 2, 2026
4258194
style(ai.agents): gofmt string concatenation in Teams guide
Jul 2, 2026
a3f7bfd
Merge remote-tracking branch 'origin/main' into feat/activity-protoco…
Copilot Jul 3, 2026
0e67902
Address Copilot review nits: bots[].botId wording, errors.AsType, beh…
Copilot Jul 3, 2026
ea1abee
fix(ai.agents): address trangevi review on activity-protocol PR
Copilot Jul 7, 2026
536335d
fix(ai.agents): decouple activity bot provisioning from optimization-…
Copilot Jul 8, 2026
aedd74f
Allow Activity protocol to coexist with other protocols
Copilot Jul 8, 2026
c140656
Add end-to-end coexistence regression for both init paths
Copilot Jul 8, 2026
3a90c84
Merge remote-tracking branch 'origin/main' into feat/activity-protoco…
Copilot Jul 8, 2026
d026d68
refactor(ai.agents): move test-only ActivityAgentEndpoint into test f…
Copilot Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,14 @@ words:
- testdir
# Test infrastructure
- recordproxy
# Activity protocol / Teams bot terms
- armbotservice
- botservice
- Azurebot
- idempotently
- sideload
- sideloading
- microsoftteams
- upsert
- m365agentstoolkit
- atk
1 change: 1 addition & 0 deletions cli/azd/extensions/azure.ai.agents/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cli/azd/extensions/azure.ai.agents/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This folds every selected protocol into the endpoint so activity can coexist with responses/invocations, but the PR description says activity is exclusive and rejected when combined (via a validateProtocolSelection that does not exist in the tree). As written there is no guard against an agent selecting activity plus responses, and postdeploy will then bind one bot to the activityProtocol messaging endpoint while the agent still advertises responses. Can we pin down the intended contract? If activity is meant to be exclusive, this call and ComposeActivityAgentEndpoint's multi-append logic reduce to ActivityAgentEndpoint(); if coexistence is intended, let's add validation or test coverage for the mixed case and correct the description.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coexistence is the intended contract. validateProtocolSelection was removed in this revision (that's why it isn't in the tree) — the PR description is stale and I'll update it to drop the "exclusive/rejected" wording.

Mixed-case coverage is already in:

  • init_from_code_test.go — an activity+responses selection now yields endpoint protocols [activity, responses] (no rejection).
  • TestComposeActivityAgentEndpoint — a coexistence subtest, plus legacy-normalization and existing-schemes-preserved cases.
  • an end-to-end regression test driving both init paths.

On postdeploy: the bot binds only the activity messaging endpoint (activity's inbound channel); responses/invocations stay served on their own protocol routes as siblings, matching the service's per-protocol config, so advertising them alongside activity is intentional and unaffected. Pure-activity output stays byte-identical to the old ActivityAgentEndpoint().

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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
130 changes: 78 additions & 52 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading