From 10446964471484678bcc177333319b36d065ce4f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:03:52 +0800 Subject: [PATCH 01/28] feat(ai.agents): generate ready-to-sideload Teams app package on deploy For activity-protocol agents, `azd deploy` postdeploy now calls the Foundry Microsoft 365 packaging API (`/agents/{name}/microsoft365/zip`) to download a ready-to-sideload Teams app package (`appPackage.zip`) next to the agent source, right after it creates the Azure Bot + Teams channel. The service builds the manifest, icons, and bot entry (botId = agent instance identity), so users no longer assemble a Teams manifest by hand. `TEAMS_APP_SETUP.md` now leads with sideloading the generated package (Teams UI or `atk --scope Personal`, no Teams admin needed) and only falls back to the manual packaging steps when package generation fails. Additive and best-effort: packaging failures are logged and never fail the deploy, and non-activity agents are completely unaffected. Publishing to the org catalog (Shared/Tenant, admin-gated) is intentionally out of scope. - Add agent_api.DownloadTeamsAppPackage + TeamsAppPackageRequest (+ tests) - Add botservice.BotArmID helper (+ test) - Wire writeTeamsAppPackage into ensureActivityBot; thread the package into the guide/next-steps (+ updated tests) - Rewrite the embedded guide with a generated-package vs manual fallback branch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../extensions/azure.ai.agents/CHANGELOG.md | 6 + .../cmd/assets/teams_app_setup_guide.md | 67 +++++---- .../internal/cmd/listen_activity.go | 129 ++++++++++++++---- .../internal/cmd/listen_activity_test.go | 34 ++++- .../pkg/agents/agent_api/microsoft365.go | 119 ++++++++++++++++ .../pkg/agents/agent_api/microsoft365_test.go | 83 +++++++++++ .../internal/pkg/botservice/botservice.go | 10 ++ .../pkg/botservice/botservice_test.go | 7 + 8 files changed, 394 insertions(+), 61 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 8b19ed35c37..0a11b44d587 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.8 (Unreleased) + +### Features Added + +- [[#XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) For activity-protocol agents, `azd deploy` now generates a ready-to-sideload Teams app package (`appPackage.zip`) next to the agent source by calling the Foundry Microsoft 365 packaging API, so you no longer have to assemble a Teams manifest and icons by hand. `TEAMS_APP_SETUP.md` now leads with sideloading that package (Teams UI or the `atk` CLI, `--scope Personal`, no Teams admin needed) and only falls back to manual packaging steps if package generation fails. Non-activity agents are unaffected. + ## 1.0.0-beta.7 (2026-07-23) ### Features Added 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 index 33d62832087..ea520472923 100644 --- 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 @@ -3,10 +3,43 @@ `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 +- Bot ID (msaAppId): `{{.MsaAppID}}` +{{- if .PackageFile}} +- Teams app package: `{{.PackageFile}}` (generated next to this guide, ready to sideload) -Two manual steps remain: (A) create a Teams app package, then (B) upload it. -They are the same for any activity-protocol agent. +One step remains: sideload the package to try your agent. Pick ONE of the two ways +below — both are per-user installs and need NO Teams admin. + +## Sideload — Teams UI (no extra tooling) + +1. In Teams, go to **Apps** -> **Manage your apps** -> **Upload an app**. +2. Select **Upload a custom app**, choose `{{.PackageFile}}`, 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 + +## Or sideload — command line (atk) + +The Microsoft 365 Agents Toolkit CLI (atk) installs the same package from a terminal. +`--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 {{.PackageFile}} --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 + +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 +{{- else}} + +azd could not generate the Teams app package automatically, so 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 @@ -70,30 +103,4 @@ 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 {{.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 +{{- end}} 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 cc4804168b5..82e64cb60db 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 @@ -10,6 +10,7 @@ import ( "fmt" "log" "os" + "path/filepath" "text/template" "azureaiagent/internal/pkg/agents/agent_api" @@ -129,31 +130,101 @@ func ensureActivityBot( return err } + // Package the Teams app for the user: the Microsoft 365 service builds a + // ready-to-sideload .zip (manifest + icons + bot entry) from the agent and the + // bot we just created, so the user no longer has to assemble a manifest by + // hand. Best-effort: on any failure we fall back to the manual packaging guide, + // so deploy never breaks and non-activity agents are unaffected. + packagePath := writeTeamsAppPackage( + ctx, agentClient, proj, svc, agentName, subscriptionID, resourceGroup, 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) + // hand the user the next steps) and print a short pointer to it. + guidePath := writeTeamsSetupGuide(proj, svc, agentName, botName, msaAppID, packagePath) + printTeamsNextSteps(botName, msaAppID, guidePath, packagePath) return nil } +// teamsAppPackageFile is the name of the generated, ready-to-sideload Teams app package. +const teamsAppPackageFile = "appPackage.zip" + +// writeTeamsAppPackage downloads a ready-to-sideload Teams app package from the +// Microsoft 365 service and writes it next to the agent source. It returns the +// written path, or "" on any failure (best-effort: never blocks or fails the +// deploy). When it returns "", the setup guide falls back to manual packaging +// steps. The generated package is scoped for per-user sideload ("Personal"), +// which needs no Teams admin. +func writeTeamsAppPackage( + ctx context.Context, + agentClient *agent_api.AgentClient, + proj *azdext.ProjectConfig, + svc *azdext.ServiceConfig, + agentName, subscriptionID, resourceGroup, botName string, +) string { + packagePath, err := paths.JoinAllowRoot(proj.GetPath(), svc.GetRelativePath(), teamsAppPackageFile) + if err != nil { + log.Printf("postdeploy: skipping Teams app package: %v", err) + return "" + } + + request := agent_api.TeamsAppPackageRequest{ + BotServiceArmID: botservice.BotArmID(subscriptionID, resourceGroup, botName), + PublishScope: "Personal", + AgentDisplayName: agentName, + AppVersion: "1.0.0", + ShortDescription: fmt.Sprintf("%s agent", agentName), + FullDescription: fmt.Sprintf("%s agent on Microsoft Teams (activity protocol)", agentName), + DeveloperName: "Azure AI Foundry", + DeveloperWebsiteURL: "https://learn.microsoft.com/azure/ai-foundry/", + PrivacyURL: "https://learn.microsoft.com/azure/ai-foundry/", + TermsOfUseURL: "https://learn.microsoft.com/azure/ai-foundry/", + CanRespondWithoutMention: true, + } + + zipBytes, err := agentClient.DownloadTeamsAppPackage( + ctx, agentName, request, agent_api.Microsoft365APIVersion, + ) + if err != nil { + log.Printf( + "postdeploy: Teams app packaging via service failed (falling back to manual guide): %v", + err, + ) + return "" + } + + if err := os.WriteFile(packagePath, zipBytes, 0o600); err != nil { + log.Printf("postdeploy: failed to write Teams app package %q: %v", packagePath, err) + return "" + } + return packagePath +} + // 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. +// to the agent source so the user can sideload their Teams app after deploy. It +// returns the written path, or "" on any failure (best-effort: never blocks or +// fails the deploy). When packagePath is set, the guide leads with sideloading +// the generated package; otherwise it falls back to manual packaging steps. 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, + proj *azdext.ProjectConfig, svc *azdext.ServiceConfig, agentName, botName, msaAppID, packagePath 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 { + packageFile := "" + if packagePath != "" { + packageFile = filepath.Base(packagePath) + } + content := teamsSetupGuideContent(agentName, botName, msaAppID, packageFile) + if err := os.WriteFile(guidePath, []byte(content), 0o600); err != nil { log.Printf("postdeploy: failed to write Teams setup guide %q: %v", guidePath, err) return "" } @@ -170,35 +241,45 @@ 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 { +// teamsSetupGuideContent renders the Teams onboarding guide markdown. When +// packageFile is set, azd already generated a ready-to-sideload package and the +// guide leads with sideloading it. When it is empty (packaging fell back), the +// guide gives concrete manual packaging steps; the single value the user must not +// get wrong there 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, packageFile 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}) + AgentName string + BotName string + MsaAppID string + PackageFile string + }{AgentName: agentName, BotName: botName, MsaAppID: msaAppID, PackageFile: packageFile}) 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) { +func printTeamsNextSteps(botName, msaAppID, guidePath, packagePath 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 packagePath != "" { + fmt.Printf(" Teams app: %s (ready to sideload)\n", packagePath) + } if guidePath != "" { - fmt.Println(output.WithGrayFormat(fmt.Sprintf( - " Next steps (package + sideload the Teams app): see %s", guidePath, - ))) + if packagePath != "" { + fmt.Println(output.WithGrayFormat(fmt.Sprintf( + " Next steps (sideload the Teams app): see %s", guidePath, + ))) + } else { + 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 " + 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 86e53054e10..0cbf8722293 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 @@ -14,12 +14,15 @@ import ( func TestTeamsSetupGuideContent(t *testing.T) { const msaAppID = "11111111-2222-3333-4444-555555555555" - content := teamsSetupGuideContent("echo-agent", "echo-agent-bot-uai", msaAppID) + + // Fallback (no generated package): the guide must give the manual packaging + // steps and carry the bot id verbatim into the sample manifest. + manual := 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) + if !strings.Contains(manual, `"botId": "`+msaAppID+`"`) { + t.Fatalf("guide must set bots[].botId to the msaAppId; got:\n%s", manual) } // The guide must point at the official Microsoft Learn docs, not any @@ -29,17 +32,34 @@ func TestTeamsSetupGuideContent(t *testing.T) { "learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload", "dev.teams.microsoft.com/apps", } { - if !strings.Contains(content, link) { + if !strings.Contains(manual, 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") { + if !strings.Contains(manual, "Upload a custom app") { t.Errorf("guide missing the concrete sideload step") } - if strings.Contains(content, "package-teams-app.ps1") { + if strings.Contains(manual, "package-teams-app.ps1") { t.Errorf("guide must not reference sample-specific scripts") } + + // Generated-package path: the guide must lead with sideloading the generated + // package (by name) and must NOT ask the user to build a manifest by hand. + const pkg = "appPackage.zip" + generated := teamsSetupGuideContent("echo-agent", "echo-agent-bot-uai", msaAppID, pkg) + if !strings.Contains(generated, pkg) { + t.Errorf("generated-package guide must reference %q", pkg) + } + if !strings.Contains(generated, "Upload a custom app") { + t.Errorf("generated-package guide missing the concrete sideload step") + } + if !strings.Contains(generated, "--scope Personal") { + t.Errorf("generated-package guide missing the atk sideload command") + } + if strings.Contains(generated, "REPLACE-WITH-A-NEW-GUID") { + t.Errorf("generated-package guide must not include the manual manifest template") + } } func TestWriteTeamsSetupGuide(t *testing.T) { @@ -50,7 +70,7 @@ func TestWriteTeamsSetupGuide(t *testing.T) { t.Fatal(err) } - path := writeTeamsSetupGuide(proj, svc, "echo-agent", "echo-agent-bot-uai", "app-id") + 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) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go new file mode 100644 index 00000000000..46dfddf45cd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "azureaiagent/internal/version" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +// Microsoft365APIVersion is the API version for the Microsoft 365 packaging +// endpoints. These endpoints are versioned independently of the agent data-plane +// APIs and currently expose only "v1". +const Microsoft365APIVersion = "v1" + +// TeamsAppPackageRequest is the body for the Microsoft 365 "zip" endpoint. The +// agent is resolved server-side from the agent name in the route, so the body +// only carries display metadata and the publish scope. Field casing matches the +// server contract (Microsoft365PublishRequestV3). +// +// For the simple activity-agent case the package is a custom engine agent backed +// by the agent's own instance identity, so PublishAsAutopilot and +// UseAgenticUserTemplate are false. PublishScope "Personal" produces a package +// intended for per-user sideload (no Teams admin required). +type TeamsAppPackageRequest struct { + PublishAsAutopilot bool `json:"PublishAsAutopilot"` + BotServiceArmID string `json:"BotServiceArmId"` + UseAgenticUserTemplate bool `json:"useAgenticUserTemplate"` + PublishScope string `json:"PublishScope"` + AgentDisplayName string `json:"AgentDisplayName"` + AppVersion string `json:"AppVersion"` + ShortDescription string `json:"ShortDescription"` + FullDescription string `json:"FullDescription"` + DeveloperName string `json:"DeveloperName"` + DeveloperWebsiteURL string `json:"DeveloperWebsiteUrl"` + PrivacyURL string `json:"PrivacyUrl"` + TermsOfUseURL string `json:"TermsOfUseUrl"` + CanRespondWithoutMention bool `json:"CanRespondWithoutMention"` +} + +// DownloadTeamsAppPackage calls the Microsoft 365 "zip" endpoint and returns the +// bytes of a ready-to-sideload Teams app package (.zip) built by the service: +// manifest, icons, and a bot entry whose botId is the agent's instance identity. +// No manifest or icon assembly happens client-side. +// +// It is best-effort at the call site: the endpoint requires an APIM-routed user +// token, so the returned error is surfaced to the caller to decide whether to +// fall back to the manual packaging guide. +func (c *AgentClient) DownloadTeamsAppPackage( + ctx context.Context, + agentName string, + request TeamsAppPackageRequest, + apiVersion string, +) ([]byte, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + u.Path += fmt.Sprintf("/agents/%s/microsoft365/zip", agentName) + + query := u.Query() + query.Set("api-version", apiVersion) + u.RawQuery = query.Encode() + + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal Teams app package request: %w", err) + } + + token, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://ai.azure.com/.default"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to get auth token: %w", err) + } + + requestCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + //nolint:gosec // request URL is built from trusted SDK endpoint + path components + req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token.Token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/zip") + req.Header.Set("Foundry-Features", "HostedAgents=V1Preview,AgentEndpoints=V1Preview") + req.Header.Set("User-Agent", fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version)) + + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("unexpected status code: %d — %s", resp.StatusCode, string(errBody)) + } + + zipBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Teams app package response: %w", err) + } + return zipBytes, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go new file mode 100644 index 00000000000..789a325c1f7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDownloadTeamsAppPackage_Success(t *testing.T) { + const zipContent = "PK\x03\x04fake-zip-bytes" + reqCh := make(chan *http.Request, 1) + bodyCh := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + select { + case reqCh <- r: + bodyCh <- b + default: + } + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(zipContent)) + })) + defer server.Close() + + client := &AgentClient{ + endpoint: server.URL, + credential: fakeCredential{}, + } + + request := TeamsAppPackageRequest{ + BotServiceArmID: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.BotService/botServices/b", + PublishScope: "Personal", + AgentDisplayName: "my-agent", + AppVersion: "1.0.0", + CanRespondWithoutMention: true, + } + + zipBytes, err := client.DownloadTeamsAppPackage(t.Context(), "my-agent", request, Microsoft365APIVersion) + require.NoError(t, err) + require.Equal(t, zipContent, string(zipBytes)) + + got := <-reqCh + require.Equal(t, http.MethodPost, got.Method) + require.Equal(t, "/agents/my-agent/microsoft365/zip", got.URL.Path) + require.Equal(t, Microsoft365APIVersion, got.URL.Query().Get("api-version")) + require.Equal(t, "application/zip", got.Header.Get("Accept")) + require.Equal(t, "application/json", got.Header.Get("Content-Type")) + require.Equal(t, "Bearer test-token", got.Header.Get("Authorization")) + + var sent TeamsAppPackageRequest + require.NoError(t, json.Unmarshal(<-bodyCh, &sent)) + require.Equal(t, request, sent) + require.False(t, sent.PublishAsAutopilot) + require.False(t, sent.UseAgenticUserTemplate) +} + +func TestDownloadTeamsAppPackage_ErrorStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":"Forbidden","message":"nope"}}`)) + })) + defer server.Close() + + client := &AgentClient{ + endpoint: server.URL, + credential: fakeCredential{}, + } + + _, err := client.DownloadTeamsAppPackage( + t.Context(), "my-agent", TeamsAppPackageRequest{}, Microsoft365APIVersion, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "403") + require.Contains(t, err.Error(), "nope") +} 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 5e012d7eb72..1a53f9b5435 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 @@ -114,6 +114,16 @@ func BotScopeSalt(subscriptionID, resourceGroup string) string { return subscriptionID + "/" + resourceGroup } +// BotArmID returns the ARM resource id of the Azure Bot Service for the given +// scope and bot name. The Microsoft 365 packaging API records it on the generated +// Teams app. +func BotArmID(subscriptionID, resourceGroup, botName string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.BotService/botServices/%s", + subscriptionID, resourceGroup, botName, + ) +} + // MessagingEndpoint returns the activity-protocol messaging endpoint URL the bot // forwards inbound Teams activities to. func MessagingEndpoint(projectEndpoint, agentName string) string { 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 8c9ec27e4d2..3590bf2546a 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 @@ -173,3 +173,10 @@ func TestMessagingEndpoint(t *testing.T) { t.Errorf("MessagingEndpoint = %q, want %q", got, want) } } + +func TestBotArmID(t *testing.T) { + want := "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.BotService/botServices/echo-bot-uai" + if got := BotArmID("sub-1", "rg-1", "echo-bot-uai"); got != want { + t.Errorf("BotArmID = %q, want %q", got, want) + } +} From 8f8f6da506fd064cf5e2bf374ba867fb88a4ae2f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:12:27 +0800 Subject: [PATCH 02/28] docs(ai.agents): set CHANGELOG PR number to #9332 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 0a11b44d587..6c35a9358ad 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- [[#XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) For activity-protocol agents, `azd deploy` now generates a ready-to-sideload Teams app package (`appPackage.zip`) next to the agent source by calling the Foundry Microsoft 365 packaging API, so you no longer have to assemble a Teams manifest and icons by hand. `TEAMS_APP_SETUP.md` now leads with sideloading that package (Teams UI or the `atk` CLI, `--scope Personal`, no Teams admin needed) and only falls back to manual packaging steps if package generation fails. Non-activity agents are unaffected. +- [[#9332]](https://github.com/Azure/azure-dev/pull/9332) For activity-protocol agents, `azd deploy` now generates a ready-to-sideload Teams app package (`appPackage.zip`) next to the agent source by calling the Foundry Microsoft 365 packaging API, so you no longer have to assemble a Teams manifest and icons by hand. `TEAMS_APP_SETUP.md` now leads with sideloading that package (Teams UI or the `atk` CLI, `--scope Personal`, no Teams admin needed) and only falls back to manual packaging steps if package generation fails. Non-activity agents are unaffected. ## 1.0.0-beta.7 (2026-07-23) From 76dcea0829dca6c8897f0627f4cd101994f56722 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:18:49 +0800 Subject: [PATCH 03/28] docs(ai.agents): correct ensureActivityBot comment for package generation The postdeploy hook now downloads a ready-to-sideload Teams app package, so the doc comment claiming packaging stays out of azd was stale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/listen_activity.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 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 82e64cb60db..f114cef9f85 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 @@ -28,12 +28,16 @@ import ( // 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. +// It provisions 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. +// +// It then best-effort downloads a ready-to-sideload Teams *app* package from the +// Microsoft 365 service and writes TEAMS_APP_SETUP.md next to the agent source. If +// packaging fails, the guide falls back to manual packaging steps, so deploy never +// breaks. Installing (sideloading) the app stays on the M365/Graph plane and is +// left to the user (per-user sideload needs no Teams admin). func ensureActivityBot( ctx context.Context, azdClient *azdext.AzdClient, From 414d649e197c55b5a840cd96646048c20d2315cb Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:40:56 +0800 Subject: [PATCH 04/28] fix(ai.agents): address PR review on Teams app packaging - Route the Microsoft 365 pack call through the shared client pipeline (retry/ correlation/bearer policies) instead of a bare http.Client, so transient 429/5xx are retried rather than dropping straight to the manual fallback. - Write the package atomically (temp file + rename) and remove any stale appPackage.zip on fallback, so a failed run can't leave a partial or mismatched zip that the guide would point users at. - When the guide write fails but the package exists, direct users to sideload the generated package instead of packaging manually. - Drop the CHANGELOG entry; changelog updates are deferred to the release PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../extensions/azure.ai.agents/CHANGELOG.md | 6 -- .../internal/cmd/listen_activity.go | 29 ++++++++- .../pkg/agents/agent_api/microsoft365.go | 61 ++++++------------- .../pkg/agents/agent_api/microsoft365_test.go | 43 +++---------- 4 files changed, 56 insertions(+), 83 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 6c35a9358ad..8b19ed35c37 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## 1.0.0-beta.8 (Unreleased) - -### Features Added - -- [[#9332]](https://github.com/Azure/azure-dev/pull/9332) For activity-protocol agents, `azd deploy` now generates a ready-to-sideload Teams app package (`appPackage.zip`) next to the agent source by calling the Foundry Microsoft 365 packaging API, so you no longer have to assemble a Teams manifest and icons by hand. `TEAMS_APP_SETUP.md` now leads with sideloading that package (Teams UI or the `atk` CLI, `--scope Personal`, no Teams admin needed) and only falls back to manual packaging steps if package generation fails. Non-activity agents are unaffected. - ## 1.0.0-beta.7 (2026-07-23) ### Features Added 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 f114cef9f85..04aeda2866e 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,6 +7,7 @@ import ( "bytes" "context" _ "embed" + "errors" "fmt" "log" "os" @@ -195,16 +196,37 @@ func writeTeamsAppPackage( "postdeploy: Teams app packaging via service failed (falling back to manual guide): %v", err, ) + removeStaleTeamsAppPackage(packagePath) return "" } - if err := os.WriteFile(packagePath, zipBytes, 0o600); err != nil { + // Write atomically (temp file + rename) so an interrupted or failed write can + // never leave a partial/corrupt zip that the guide would point users at. + tmpPath := packagePath + ".tmp" + if err := os.WriteFile(tmpPath, zipBytes, 0o600); err != nil { log.Printf("postdeploy: failed to write Teams app package %q: %v", packagePath, err) + _ = os.Remove(tmpPath) + removeStaleTeamsAppPackage(packagePath) + return "" + } + if err := os.Rename(tmpPath, packagePath); err != nil { + log.Printf("postdeploy: failed to finalize Teams app package %q: %v", packagePath, err) + _ = os.Remove(tmpPath) + removeStaleTeamsAppPackage(packagePath) return "" } return packagePath } +// removeStaleTeamsAppPackage deletes a leftover package from a previous deploy so +// a failed or skipped packaging run cannot leave a stale zip whose manifest and +// bot binding no longer match the current deployment. A missing file is fine. +func removeStaleTeamsAppPackage(packagePath string) { + if err := os.Remove(packagePath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("postdeploy: could not remove stale Teams app package %q: %v", packagePath, err) + } +} + // teamsSetupGuideFile is the name of the generated Teams onboarding guide. const teamsSetupGuideFile = "TEAMS_APP_SETUP.md" @@ -284,6 +306,11 @@ func printTeamsNextSteps(botName, msaAppID, guidePath, packagePath string) { " Next steps (package + sideload the Teams app): see %s", guidePath, ))) } + } else if packagePath != "" { + fmt.Println(output.WithGrayFormat(fmt.Sprintf( + " Next steps: sideload %s in Teams -> Apps -> Manage your apps -> Upload a custom app.", + packagePath, + ))) } else { fmt.Println(output.WithGrayFormat( " Next steps: package the Teams app (bots[].botId = the Bot ID above) and " + diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go index 46dfddf45cd..78def375034 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go @@ -4,18 +4,13 @@ package agent_api import ( - "bytes" "context" - "encoding/json" "fmt" "io" "net/http" - "net/url" "time" - "azureaiagent/internal/version" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" ) // Microsoft365APIVersion is the API version for the Microsoft 365 packaging @@ -56,59 +51,41 @@ type TeamsAppPackageRequest struct { // It is best-effort at the call site: the endpoint requires an APIM-routed user // token, so the returned error is surfaced to the caller to decide whether to // fall back to the manual packaging guide. +// +// The request goes through the shared client pipeline (c.pipeline) so it inherits +// the same bearer-token, retry, and correlation policies as every other agent +// data-plane call; a transient 429/5xx is retried rather than immediately falling +// back to the manual guide. A per-call timeout bounds the total wait so a hung +// service can never block deploy. func (c *AgentClient) DownloadTeamsAppPackage( ctx context.Context, agentName string, request TeamsAppPackageRequest, apiVersion string, ) ([]byte, error) { - u, err := url.Parse(c.endpoint) - if err != nil { - return nil, fmt.Errorf("invalid endpoint URL: %w", err) - } - u.Path += fmt.Sprintf("/agents/%s/microsoft365/zip", agentName) - - query := u.Query() - query.Set("api-version", apiVersion) - u.RawQuery = query.Encode() - - body, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("failed to marshal Teams app package request: %w", err) - } - - token, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: []string{"https://ai.azure.com/.default"}, - }) - if err != nil { - return nil, fmt.Errorf("failed to get auth token: %w", err) - } - - requestCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() - //nolint:gosec // request URL is built from trusted SDK endpoint + path components - req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, u.String(), bytes.NewReader(body)) + url := fmt.Sprintf("%s/agents/%s/microsoft365/zip?api-version=%s", c.endpoint, agentName, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodPost, url) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } + if err := runtime.MarshalAsJSON(req, request); err != nil { + return nil, fmt.Errorf("failed to marshal Teams app package request: %w", err) + } + req.Raw().Header.Set("Accept", "application/zip") + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview,AgentEndpoints=V1Preview") - req.Header.Set("Authorization", "Bearer "+token.Token) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/zip") - req.Header.Set("Foundry-Features", "HostedAgents=V1Preview,AgentEndpoints=V1Preview") - req.Header.Set("User-Agent", fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version)) - - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := c.pipeline.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("unexpected status code: %d — %s", resp.StatusCode, string(errBody)) + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) } zipBytes, err := io.ReadAll(resp.Body) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go index 789a325c1f7..3f5e6c18d05 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go @@ -7,7 +7,6 @@ import ( "encoding/json" "io" "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -15,25 +14,7 @@ import ( func TestDownloadTeamsAppPackage_Success(t *testing.T) { const zipContent = "PK\x03\x04fake-zip-bytes" - reqCh := make(chan *http.Request, 1) - bodyCh := make(chan []byte, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - select { - case reqCh <- r: - bodyCh <- b - default: - } - w.Header().Set("Content-Type", "application/zip") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(zipContent)) - })) - defer server.Close() - - client := &AgentClient{ - endpoint: server.URL, - credential: fakeCredential{}, - } + client, transport := newCaptureClient(http.StatusOK, zipContent) request := TeamsAppPackageRequest{ BotServiceArmID: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.BotService/botServices/b", @@ -47,32 +28,26 @@ func TestDownloadTeamsAppPackage_Success(t *testing.T) { require.NoError(t, err) require.Equal(t, zipContent, string(zipBytes)) - got := <-reqCh + require.Len(t, transport.requests, 1) + got := transport.requests[0] require.Equal(t, http.MethodPost, got.Method) - require.Equal(t, "/agents/my-agent/microsoft365/zip", got.URL.Path) + require.Equal(t, "/api/projects/proj/agents/my-agent/microsoft365/zip", got.URL.Path) require.Equal(t, Microsoft365APIVersion, got.URL.Query().Get("api-version")) require.Equal(t, "application/zip", got.Header.Get("Accept")) require.Equal(t, "application/json", got.Header.Get("Content-Type")) - require.Equal(t, "Bearer test-token", got.Header.Get("Authorization")) + require.Equal(t, "HostedAgents=V1Preview,AgentEndpoints=V1Preview", got.Header.Get("Foundry-Features")) + bodyBytes, err := io.ReadAll(got.Body) + require.NoError(t, err) var sent TeamsAppPackageRequest - require.NoError(t, json.Unmarshal(<-bodyCh, &sent)) + require.NoError(t, json.Unmarshal(bodyBytes, &sent)) require.Equal(t, request, sent) require.False(t, sent.PublishAsAutopilot) require.False(t, sent.UseAgenticUserTemplate) } func TestDownloadTeamsAppPackage_ErrorStatus(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":{"code":"Forbidden","message":"nope"}}`)) - })) - defer server.Close() - - client := &AgentClient{ - endpoint: server.URL, - credential: fakeCredential{}, - } + client, _ := newCaptureClient(http.StatusForbidden, `{"error":{"code":"Forbidden","message":"nope"}}`) _, err := client.DownloadTeamsAppPackage( t.Context(), "my-agent", TeamsAppPackageRequest{}, Microsoft365APIVersion, From 7cf502fe403bbd7f0c80acf0aff89d4b782cbb9f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:12:01 +0800 Subject: [PATCH 05/28] fix(ai.agents): only remove/overwrite azd-generated Teams packages The generated appPackage.zip lives in the user's source dir under a generic name, so a user may keep their own manually assembled package there. Track azd ownership with a sidecar marker (.appPackage.zip.azd-generated): azd now only overwrites or removes the package when the marker is present, preserving an unowned user file on both the success and fallback paths. Add regression tests covering preserve-unowned, write-when-absent, overwrite-owned, and remove-owned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/listen_activity.go | 80 ++++++++++--- .../internal/cmd/listen_activity_test.go | 105 ++++++++++++++++++ 2 files changed, 167 insertions(+), 18 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 04aeda2866e..298d6ea6493 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 @@ -155,12 +155,19 @@ func ensureActivityBot( // teamsAppPackageFile is the name of the generated, ready-to-sideload Teams app package. const teamsAppPackageFile = "appPackage.zip" +// teamsAppPackageMarkerFile records that teamsAppPackageFile was generated by azd. +// Because the package lives in the user's source directory under a generic name, a +// user may keep their own manually assembled appPackage.zip there. azd only ever +// overwrites or removes a package when this sidecar marker is present, so an +// unowned user file is never clobbered. +const teamsAppPackageMarkerFile = ".appPackage.zip.azd-generated" + // writeTeamsAppPackage downloads a ready-to-sideload Teams app package from the // Microsoft 365 service and writes it next to the agent source. It returns the -// written path, or "" on any failure (best-effort: never blocks or fails the -// deploy). When it returns "", the setup guide falls back to manual packaging -// steps. The generated package is scoped for per-user sideload ("Personal"), -// which needs no Teams admin. +// written path, or "" on any failure or when a pre-existing user-owned package is +// left in place (best-effort: never blocks or fails the deploy). When it returns +// "", the setup guide falls back to manual packaging steps. The generated package +// is scoped for per-user sideload ("Personal"), which needs no Teams admin. func writeTeamsAppPackage( ctx context.Context, agentClient *agent_api.AgentClient, @@ -173,6 +180,7 @@ func writeTeamsAppPackage( log.Printf("postdeploy: skipping Teams app package: %v", err) return "" } + markerPath := filepath.Join(filepath.Dir(packagePath), teamsAppPackageMarkerFile) request := agent_api.TeamsAppPackageRequest{ BotServiceArmID: botservice.BotArmID(subscriptionID, resourceGroup, botName), @@ -196,35 +204,71 @@ func writeTeamsAppPackage( "postdeploy: Teams app packaging via service failed (falling back to manual guide): %v", err, ) - removeStaleTeamsAppPackage(packagePath) + removeOwnedTeamsAppPackage(packagePath, markerPath) return "" } - // Write atomically (temp file + rename) so an interrupted or failed write can - // never leave a partial/corrupt zip that the guide would point users at. + written, err := commitTeamsAppPackage(packagePath, markerPath, zipBytes) + if err != nil { + log.Printf("postdeploy: failed to write Teams app package %q: %v", packagePath, err) + return "" + } + return written +} + +// teamsAppPackageIsOwned reports whether appPackage.zip was generated by azd, as +// recorded by the sidecar marker. azd only removes or overwrites packages it owns. +func teamsAppPackageIsOwned(markerPath string) bool { + _, err := os.Stat(markerPath) + return err == nil +} + +// commitTeamsAppPackage writes the generated package atomically (temp file + +// rename, so an interrupted write cannot leave a partial/corrupt zip) and records +// the ownership marker. If a package already exists that azd does not own (a user's +// own manually assembled or downloaded zip), it is preserved untouched and "" is +// returned so the manual guide is used instead of clobbering the user's file. +func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (string, error) { + if _, err := os.Stat(packagePath); err == nil && !teamsAppPackageIsOwned(markerPath) { + log.Printf( + "postdeploy: %q already exists and was not generated by azd; leaving it untouched", + packagePath, + ) + return "", nil + } + tmpPath := packagePath + ".tmp" if err := os.WriteFile(tmpPath, zipBytes, 0o600); err != nil { - log.Printf("postdeploy: failed to write Teams app package %q: %v", packagePath, err) _ = os.Remove(tmpPath) - removeStaleTeamsAppPackage(packagePath) - return "" + return "", err } if err := os.Rename(tmpPath, packagePath); err != nil { - log.Printf("postdeploy: failed to finalize Teams app package %q: %v", packagePath, err) _ = os.Remove(tmpPath) - removeStaleTeamsAppPackage(packagePath) - return "" + return "", err + } + // Best-effort marker: if it can't be written the zip is still valid; azd just + // won't recognize the file as its own on a later run. + if err := os.WriteFile(markerPath, []byte("generated by azd\n"), 0o600); err != nil { + log.Printf("postdeploy: could not write Teams app package ownership marker %q: %v", markerPath, err) } - return packagePath + return packagePath, nil } -// removeStaleTeamsAppPackage deletes a leftover package from a previous deploy so -// a failed or skipped packaging run cannot leave a stale zip whose manifest and -// bot binding no longer match the current deployment. A missing file is fine. -func removeStaleTeamsAppPackage(packagePath string) { +// removeOwnedTeamsAppPackage deletes a leftover azd-generated package (and its +// marker) from a previous deploy so a failed or skipped packaging run cannot leave +// a stale zip whose manifest and bot binding no longer match the current +// deployment. A package azd does not own (no marker) or a missing file is left +// untouched. +func removeOwnedTeamsAppPackage(packagePath, markerPath string) { + if !teamsAppPackageIsOwned(markerPath) { + return + } if err := os.Remove(packagePath); err != nil && !errors.Is(err, os.ErrNotExist) { log.Printf("postdeploy: could not remove stale Teams app package %q: %v", packagePath, err) } + if err := os.Remove(markerPath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("postdeploy: could not remove Teams app package marker %q: %v", markerPath, err) + } } // teamsSetupGuideFile is the name of the generated Teams onboarding guide. 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 0cbf8722293..b10694dcf92 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 @@ -83,3 +83,108 @@ func TestWriteTeamsSetupGuide(t *testing.T) { t.Errorf("written guide missing bot id") } } + +func TestCommitTeamsAppPackage_PreservesUnownedFile(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("USER-OWNED"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := commitTeamsAppPackage(pkg, marker, []byte("AZD-GENERATED")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("expected empty path when leaving an unowned file, got %q", got) + } + data, _ := os.ReadFile(pkg) + if string(data) != "USER-OWNED" { + t.Errorf("user file was clobbered; content = %q", string(data)) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Errorf("marker must not be created for an unowned file") + } +} + +func TestCommitTeamsAppPackage_WritesWhenAbsent(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + + got, err := commitTeamsAppPackage(pkg, marker, []byte("AZD-GENERATED")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != pkg { + t.Errorf("path = %q, want %q", got, pkg) + } + data, _ := os.ReadFile(pkg) + if string(data) != "AZD-GENERATED" { + t.Errorf("package content = %q", string(data)) + } + if !teamsAppPackageIsOwned(marker) { + t.Errorf("ownership marker must be written") + } +} + +func TestCommitTeamsAppPackage_OverwritesOwned(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("OLD"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte("generated by azd\n"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := commitTeamsAppPackage(pkg, marker, []byte("NEW")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != pkg { + t.Errorf("path = %q, want %q", got, pkg) + } + data, _ := os.ReadFile(pkg) + if string(data) != "NEW" { + t.Errorf("owned package should be overwritten; content = %q", string(data)) + } +} + +func TestRemoveOwnedTeamsAppPackage_PreservesUnowned(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("USER-OWNED"), 0o600); err != nil { + t.Fatal(err) + } + + removeOwnedTeamsAppPackage(pkg, marker) + + if _, err := os.Stat(pkg); err != nil { + t.Errorf("unowned package must be preserved: %v", err) + } +} + +func TestRemoveOwnedTeamsAppPackage_RemovesOwned(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("AZD-GENERATED"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte("generated by azd\n"), 0o600); err != nil { + t.Fatal(err) + } + + removeOwnedTeamsAppPackage(pkg, marker) + + if _, err := os.Stat(pkg); !os.IsNotExist(err) { + t.Errorf("owned package must be removed") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Errorf("marker must be removed") + } +} From ad2af3d6ba73a955c1c74f1c3ccdec7b328eb5c6 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:21:39 +0800 Subject: [PATCH 06/28] feat(ai.agents): add `azd ai agent pack` and `azd ai agent publish` Add two explicit, loud commands for Activity (Teams) agents that complement the deploy-time best-effort packaging: - `azd ai agent pack` downloads a ready-to-sideload Teams app package (appPackage.zip) from the Foundry Microsoft 365 zip endpoint for local customization and `atk install --scope personal` sideloading. - `azd ai agent publish` builds and publishes the package to the Microsoft 365 store. Defaults to `shared` scope (shareable install link, no tenant-admin approval) and supports `org` scope (organization catalog, admin-gated). `personal` is rejected with guidance to use pack + sideload, since per-user install is a Teams client action, not a store publish. Both require a prior `azd deploy` (the Azure Bot they bind to is created then) and are activity-agent only. Unlike the postdeploy hook, platform failures (tenant policy, permissions, service errors) surface as command failures instead of silent fallbacks. - Add agent_api.PublishTeamsApp + TeamsAppPublishResult - Add shared resolveTeamsPackContext / scope + request helpers (+ tests) - Register pack/publish in root; add exterrors codes/ops Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../extensions/azure.ai.agents/CHANGELOG.md | 6 + .../azure.ai.agents/internal/cmd/pack.go | 170 ++++++++++++ .../azure.ai.agents/internal/cmd/publish.go | 166 ++++++++++++ .../azure.ai.agents/internal/cmd/root.go | 2 + .../internal/cmd/teams_pack.go | 251 ++++++++++++++++++ .../internal/cmd/teams_pack_test.go | 98 +++++++ .../internal/exterrors/codes.go | 9 + .../pkg/agents/agent_api/microsoft365.go | 66 +++++ 8 files changed, 768 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 8b19ed35c37..6196d1cb32f 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.8 (Unreleased) + +### Features Added + +- [[#9332]](https://github.com/Azure/azure-dev/pull/9332) Add `azd ai agent pack` and `azd ai agent publish` for Activity (Teams) agents. `pack` downloads a ready-to-sideload Teams app package (`appPackage.zip`) from the Foundry Microsoft 365 service for local customization and `atk install --scope personal` sideloading; `publish` builds and publishes the package to the Microsoft 365 store, defaulting to `shared` scope (shareable install link, no tenant-admin approval) and supporting `org` scope (organization catalog, admin-gated). Both require a prior `azd deploy` and surface platform failures (tenant policy, permissions, service errors) as command failures rather than silently skipping. + ## 1.0.0-beta.7 (2026-07-23) ### Features Added diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go new file mode 100644 index 00000000000..a45605008c6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/paths" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type packFlags struct { + name string + scope string + displayName string + appVersion string + outputDir string + noPrompt bool +} + +func newPackCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &packFlags{} + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "pack [name]", + Short: "Build a ready-to-sideload Teams app package for an activity agent.", + Long: `Build a Teams app package (appPackage.zip) for a deployed Activity (Teams) agent. + +The Microsoft 365 service assembles the package (manifest, icons, and a bot entry +bound to the agent's instance identity) from the agent and the Azure Bot created +during 'azd deploy'. The package is written next to the agent source (or to +--output-dir) so you can sideload it in Teams. + +This command requires the agent to have been deployed first ('azd deploy'). Unlike +the best-effort packaging done during deploy, any failure here (tenant policy, +permissions, service outage) is reported as a command failure.`, + Example: ` # Pack the agent resolved from azure.yaml (personal scope) + azd ai agent pack + + # Pack a specific agent for shareable-link distribution + azd ai agent pack my-agent --scope shared + + # Pack with a custom display name and output directory + azd ai agent pack --display-name "Contoso Helper" --output-dir ./dist`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + flags.name = args[0] + } + flags.noPrompt = extCtx.NoPrompt + + ctx := azdext.WithAccessToken(cmd.Context()) + action := &PackAction{flags: flags} + return action.Run(ctx) + }, + } + + cmd.Flags().StringVar(&flags.scope, "scope", "personal", + fmt.Sprintf("Publish scope for the package (%s)", joinScopeHelp())) + cmd.Flags().StringVar(&flags.displayName, "display-name", "", + "Display name for the Teams app (defaults to the agent name)") + cmd.Flags().StringVar(&flags.appVersion, "app-version", "1.0.0", + "Version stamped into the Teams app manifest") + cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", + "Directory to write appPackage.zip to (defaults to the agent source directory)") + + return cmd +} + +// PackAction implements the agent pack command. +type PackAction struct { + flags *packFlags +} + +func (a *PackAction) Run(ctx context.Context) error { + scope, err := resolveTeamsPackScope(a.flags.scope) + if err != nil { + return err + } + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + packCtx, err := resolveTeamsPackContext(ctx, azdClient, a.flags.name, a.flags.noPrompt) + if err != nil { + return err + } + + displayName := a.flags.displayName + if displayName == "" { + displayName = packCtx.agentName + } + + request := buildTeamsAppPackageRequest(packCtx.botArmID, teamsAppRequestOptions{ + scope: scope, + displayName: displayName, + appVersion: a.flags.appVersion, + }) + + outputPath, err := a.resolveOutputPath(packCtx) + if err != nil { + return err + } + + fmt.Printf("Packing Teams app for agent %q (scope: %s)...\n", packCtx.agentName, scope.flag) + + zipBytes, err := packCtx.agentClient.DownloadTeamsAppPackage( + ctx, packCtx.agentName, request, agent_api.Microsoft365APIVersion, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpPackTeamsApp) + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + if err := os.WriteFile(outputPath, zipBytes, 0o600); err != nil { + return fmt.Errorf("failed to write Teams app package %q: %w", outputPath, err) + } + + fmt.Printf("Teams app package written to %s\n", outputPath) + fmt.Println("Sideload it in Teams (no admin required for personal scope), or run " + + "'azd ai agent publish' to publish it to the Microsoft 365 store.") + return nil +} + +// resolveOutputPath returns the absolute path appPackage.zip is written to. When +// --output-dir is set it is joined with the package file name; otherwise the +// package is written next to the agent source, matching the deploy hook's location. +func (a *PackAction) resolveOutputPath(packCtx *teamsPackContext) (string, error) { + if a.flags.outputDir != "" { + abs, err := filepath.Abs(filepath.Join(a.flags.outputDir, teamsAppPackageFile)) + if err != nil { + return "", fmt.Errorf("invalid --output-dir %q: %w", a.flags.outputDir, err) + } + return abs, nil + } + outputPath, err := paths.JoinAllowRoot( + packCtx.proj.GetPath(), packCtx.svc.GetRelativePath(), teamsAppPackageFile, + ) + if err != nil { + return "", fmt.Errorf("failed to resolve output path: %w", err) + } + return outputPath, nil +} + +// joinScopeHelp renders the supported scope values with their one-line summaries +// for the --scope flag help text. +func joinScopeHelp() string { + out := "" + for i, scope := range teamsPackScopes { + if i > 0 { + out += "; " + } + out += fmt.Sprintf("%s: %s", scope.flag, scope.summary) + } + return out +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go new file mode 100644 index 00000000000..781e8691d66 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type publishFlags struct { + name string + scope string + displayName string + appVersion string + output string + noPrompt bool +} + +func newPublishCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &publishFlags{} + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "publish [name]", + Short: "Publish an activity agent as a Teams app to the Microsoft 365 store.", + Long: `Publish a deployed Activity (Teams) agent as a Teams app. + +The Microsoft 365 service builds the Teams app package server-side (the same package +'azd ai agent pack' produces) and publishes it to the Microsoft 365 store under the +requested scope, then returns the published title id and Teams app id. + +Scopes: + shared shareable-link distribution (no tenant-admin approval required) [default] + org organization-wide catalog (requires IT-admin approval) + +'personal' is not supported here: per-user install is a Teams client action, not a +store publish. For local testing, run 'azd ai agent pack' and sideload with +'atk install --scope personal'. + +This command requires the agent to have been deployed first ('azd deploy'). Any +failure (tenant policy, permissions, service outage) is reported as a command +failure rather than silently skipped.`, + Example: ` # Publish for shareable-link distribution (default) + azd ai agent publish + + # Publish a specific agent + azd ai agent publish my-agent --scope shared + + # Publish organization-wide (requires IT-admin approval) + azd ai agent publish --scope org`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + flags.name = args[0] + } + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + + ctx := azdext.WithAccessToken(cmd.Context()) + action := &PublishAction{flags: flags} + return action.Run(ctx) + }, + } + + cmd.Flags().StringVar(&flags.scope, "scope", "shared", + fmt.Sprintf("Publish scope (%s)", joinScopeHelp())) + cmd.Flags().StringVar(&flags.displayName, "display-name", "", + "Display name for the Teams app (defaults to the agent name)") + cmd.Flags().StringVar(&flags.appVersion, "app-version", "1.0.0", + "Version stamped into the Teams app manifest") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "none"}, + Default: "none", + }) + + return cmd +} + +// PublishAction implements the agent publish command. +type PublishAction struct { + flags *publishFlags +} + +func (a *PublishAction) Run(ctx context.Context) error { + scope, err := resolveTeamsPackScope(a.flags.scope) + if err != nil { + return err + } + // The Microsoft 365 publish backend does not support "personal": installing an + // app for a single user (personal sideload) is a Teams client action, not a store + // publish. Reject it loudly and point to the pack + sideload path instead of + // silently publishing to a different audience. + if err := validatePublishScope(scope); err != nil { + return err + } + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + packCtx, err := resolveTeamsPackContext(ctx, azdClient, a.flags.name, a.flags.noPrompt) + if err != nil { + return err + } + + displayName := a.flags.displayName + if displayName == "" { + displayName = packCtx.agentName + } + + request := buildTeamsAppPackageRequest(packCtx.botArmID, teamsAppRequestOptions{ + scope: scope, + displayName: displayName, + appVersion: a.flags.appVersion, + }) + + fmt.Printf("Publishing Teams app for agent %q (scope: %s)...\n", packCtx.agentName, scope.flag) + + result, err := packCtx.agentClient.PublishTeamsApp( + ctx, packCtx.agentName, request, agent_api.Microsoft365APIVersion, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpPublishTeamsApp) + } + + deepLink := teamsAppDeepLink(result.TeamsAppID) + + if a.flags.output == "json" { + payload := map[string]string{ + "titleId": result.TitleID, + "teamsAppId": result.TeamsAppID, + "scope": scope.flag, + "deepLink": deepLink, + } + data, jsonErr := json.MarshalIndent(payload, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + return nil + } + + fmt.Printf("Published Teams app for agent %q (scope: %s)\n", packCtx.agentName, scope.flag) + fmt.Printf(" Title ID: %s\n", result.TitleID) + fmt.Printf(" Teams App ID: %s\n", result.TeamsAppID) + fmt.Printf(" Install link: %s\n", deepLink) + switch scope.flag { + case "org": + fmt.Println("The app is submitted to the organization catalog and awaits IT-admin approval.") + default: + fmt.Println("Share the install link above; recipients can add the app without tenant-admin approval.") + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index 49f1c6f0bb3..e102b40d399 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -58,6 +58,8 @@ func NewRootCommand() *cobra.Command { })) rootCmd.AddCommand(newShowCommand(extCtx)) rootCmd.AddCommand(newDeleteCommand(extCtx)) + rootCmd.AddCommand(newPackCommand(extCtx)) + rootCmd.AddCommand(newPublishCommand(extCtx)) rootCmd.AddCommand(newEndpointCommand(extCtx)) rootCmd.AddCommand(newMonitorCommand(extCtx)) rootCmd.AddCommand(newFilesCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go new file mode 100644 index 00000000000..ba1f3b6ccea --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "sort" + "strings" + + "azureaiagent/internal/exterrors" + "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-dev/cli/azd/pkg/azdext" +) + +// teamsPackScope is a user-facing Teams publish scope. It maps onto the +// Microsoft 365 service's PublishScope contract (Microsoft365Constants: +// Personal/Shared/Tenant). +type teamsPackScope struct { + // flag is the value the user types on the command line (lowercase). + flag string + // api is the corresponding PublishScope value sent to the service. + api string + // summary explains, in one line, what the scope means for distribution. + summary string +} + +// teamsPackScopes enumerates the supported publish scopes. "personal" is the +// default because it needs no Teams admin approval (per-user sideload); "shared" +// distributes via a shareable link without tenant-admin approval; "org" publishes +// to the whole organization and requires IT-admin approval. +var teamsPackScopes = []teamsPackScope{ + {flag: "personal", api: "Personal", summary: "per-user sideload (no admin approval required)"}, + {flag: "shared", api: "Shared", summary: "shareable link distribution (no tenant-admin approval required)"}, + {flag: "org", api: "Tenant", summary: "organization-wide catalog (requires IT-admin approval)"}, +} + +// resolveTeamsPackScope validates a user-supplied scope value and returns the +// matching scope descriptor. An empty value resolves to the default ("personal"). +// An unknown value is a loud, actionable validation error rather than a silent +// fallback, so a typo can never quietly publish to the wrong audience. +func resolveTeamsPackScope(value string) (teamsPackScope, error) { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + normalized = "personal" + } + // "tenant" is accepted as an alias for "org" because that is the underlying + // PublishScope value; users familiar with the service contract may reach for it. + if normalized == "tenant" { + normalized = "org" + } + for _, scope := range teamsPackScopes { + if scope.flag == normalized { + return scope, nil + } + } + return teamsPackScope{}, exterrors.Validation( + exterrors.CodeInvalidPublishScope, + fmt.Sprintf("unsupported Teams publish scope %q", value), + fmt.Sprintf("use one of: %s", strings.Join(teamsPackScopeFlags(), ", ")), + ) +} + +// teamsPackScopeFlags returns the supported scope flag values in a stable order, +// for help text and error hints. +func teamsPackScopeFlags() []string { + flags := make([]string, 0, len(teamsPackScopes)) + for _, scope := range teamsPackScopes { + flags = append(flags, scope.flag) + } + sort.Strings(flags) + return flags +} + +// teamsPackContext holds everything the pack and publish commands need after +// resolving the target agent from the azd project and environment. Both commands +// operate on an agent that has already been deployed (the Azure Bot is created +// during 'azd deploy'), so this resolver requires a recorded deployment and fails +// loudly when the agent is missing, is not an activity agent, or has not been +// deployed yet. +type teamsPackContext struct { + azdClient *azdext.AzdClient + proj *azdext.ProjectConfig + svc *azdext.ServiceConfig + agentName string + botArmID string + credential azcore.TokenCredential + agentClient *agent_api.AgentClient +} + +// resolveTeamsPackContext resolves the target activity agent and derives the Azure +// Bot ARM id, credential, and Microsoft 365 client shared by the pack and publish +// commands. It enforces the preconditions loudly: +// - the resolved service must be a hosted agent that speaks the Activity protocol +// (pack/publish only make sense for Teams-facing agents); +// - the agent must already be deployed (an AGENT_{KEY}_VERSION recorded by +// 'azd deploy'), because the bot the Teams app binds to is created during deploy. +func resolveTeamsPackContext( + ctx context.Context, + azdClient *azdext.AzdClient, + name string, + noPrompt bool, +) (*teamsPackContext, error) { + svc, proj, err := resolveAgentService(ctx, azdClient, name, noPrompt) + if err != nil { + return nil, err + } + + ca, isHosted, _, err := project.LoadAgentDefinition(svc, proj.Path) + if err != nil { + return nil, err + } + if !isHosted || !project.ResolveActivityProfile(ca).IsActivity { + return nil, exterrors.Validation( + exterrors.CodeNotActivityAgent, + fmt.Sprintf("agent service %q is not an Activity (Teams) agent", svc.Name), + "'azd ai agent pack' and 'azd ai agent publish' only apply to hosted agents that "+ + "speak the Activity protocol; check the agent's protocols in azure.yaml", + ) + } + + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + "no azd environment is selected", + "run 'azd env select ' or deploy the agent with 'azd deploy' first", + ) + } + envName := envResp.Environment.Name + + // The API agent name is the service name (deploy binds the bot with it), so the + // bot name derived below must use the same value. + agentName := svc.Name + + // A recorded version is the signal that 'azd deploy' has run and created the bot + // this Teams app binds to. Without it, packaging would reference a bot that does + // not exist, so fail loudly with a clear next step instead of producing a broken + // package. + serviceKey := toServiceKey(svc.Name) + version, err := readEnvValue(ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_VERSION", serviceKey)) + if err != nil { + return nil, err + } + if strings.TrimSpace(version) == "" { + return nil, exterrors.Dependency( + exterrors.CodeAgentNotDeployed, + fmt.Sprintf("agent %q has not been deployed in environment %q", agentName, envName), + "run 'azd deploy' first; the Teams bot is created during deploy and is required "+ + "before packaging or publishing", + ) + } + + subscriptionID, err := readEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID") + if err != nil { + return nil, err + } + resourceGroup, err := readEnvValue(ctx, azdClient, envName, "AZURE_RESOURCE_GROUP") + if err != nil { + return nil, err + } + + botName := botservice.BotName(agentName, botservice.BotScopeSalt(subscriptionID, resourceGroup)) + botArmID := botservice.BotArmID(subscriptionID, resourceGroup, botName) + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return nil, err + } + credential, err := newAgentCredential() + if err != nil { + return nil, err + } + + return &teamsPackContext{ + azdClient: azdClient, + proj: proj, + svc: svc, + agentName: agentName, + botArmID: botArmID, + credential: credential, + agentClient: agent_api.NewAgentClient(endpoint, credential), + }, nil +} + +// teamsAppRequestOptions carries the user-overridable display metadata for a Teams +// app package/publish request. Zero-value fields fall back to sensible defaults +// derived from the agent name. +type teamsAppRequestOptions struct { + scope teamsPackScope + displayName string + appVersion string +} + +// buildTeamsAppPackageRequest assembles the Microsoft 365 request body shared by +// the zip (pack) and publish endpoints. Only display metadata and the publish +// scope vary; the agent itself is resolved server-side from the route. Defaults +// mirror the postdeploy packaging path so a command run with no flags produces the +// same package the deploy hook would. +func buildTeamsAppPackageRequest( + botArmID string, + opts teamsAppRequestOptions, +) agent_api.TeamsAppPackageRequest { + displayName := strings.TrimSpace(opts.displayName) + appVersion := strings.TrimSpace(opts.appVersion) + if appVersion == "" { + appVersion = "1.0.0" + } + return agent_api.TeamsAppPackageRequest{ + BotServiceArmID: botArmID, + PublishScope: opts.scope.api, + AgentDisplayName: displayName, + AppVersion: appVersion, + ShortDescription: fmt.Sprintf("%s agent", displayName), + FullDescription: fmt.Sprintf("%s agent on Microsoft Teams (activity protocol)", displayName), + DeveloperName: "Azure AI Foundry", + DeveloperWebsiteURL: "https://learn.microsoft.com/azure/ai-foundry/", + PrivacyURL: "https://learn.microsoft.com/azure/ai-foundry/", + TermsOfUseURL: "https://learn.microsoft.com/azure/ai-foundry/", + CanRespondWithoutMention: true, + } +} + +// teamsAppDeepLink returns the Teams deep link that installs the published custom +// engine agent. Anyone the app is shared with (for the "shared" scope) or the +// publishing user (for "personal") can open it to add the app in Teams. +func teamsAppDeepLink(teamsAppID string) string { + return fmt.Sprintf("https://teams.microsoft.com/l/app/%s", teamsAppID) +} + +// validatePublishScope rejects scopes the Microsoft 365 publish backend does not +// support. "personal" (per-user install) is a Teams client action, not a store +// publish, so 'azd ai agent publish' cannot fulfill it; the user is pointed to the +// 'azd ai agent pack' + 'atk install' path instead. +func validatePublishScope(scope teamsPackScope) error { + if scope.flag == "personal" { + return exterrors.Validation( + exterrors.CodeInvalidPublishScope, + "'personal' scope is not supported by 'azd ai agent publish'", + "personal install is a Teams client action, not a store publish; run "+ + "'azd ai agent pack' and sideload with 'atk install --scope personal', "+ + "or publish with --scope shared or --scope org", + ) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go new file mode 100644 index 00000000000..2b48a3ade8c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "testing" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func TestResolveTeamsPackScope(t *testing.T) { + cases := []struct { + in string + wantAPI string + }{ + {"", "Personal"}, + {"personal", "Personal"}, + {"PERSONAL", "Personal"}, + {" shared ", "Shared"}, + {"org", "Tenant"}, + {"tenant", "Tenant"}, + } + for _, c := range cases { + got, err := resolveTeamsPackScope(c.in) + if err != nil { + t.Fatalf("resolveTeamsPackScope(%q) returned error: %v", c.in, err) + } + if got.api != c.wantAPI { + t.Errorf("resolveTeamsPackScope(%q).api = %q, want %q", c.in, got.api, c.wantAPI) + } + } +} + +func TestResolveTeamsPackScopeInvalid(t *testing.T) { + _, err := resolveTeamsPackScope("everyone") + if err == nil { + t.Fatal("expected error for unsupported scope, got nil") + } + var localErr *azdext.LocalError + if !errors.As(err, &localErr) { + t.Fatalf("expected *azdext.LocalError, got %T", err) + } + if localErr.Code != exterrors.CodeInvalidPublishScope { + t.Errorf("error code = %q, want %q", localErr.Code, exterrors.CodeInvalidPublishScope) + } +} + +func TestBuildTeamsAppPackageRequest(t *testing.T) { + scope, err := resolveTeamsPackScope("shared") + if err != nil { + t.Fatal(err) + } + req := buildTeamsAppPackageRequest("/subscriptions/s/bot", teamsAppRequestOptions{ + scope: scope, + displayName: "Contoso Helper", + appVersion: "", + }) + if req.BotServiceArmID != "/subscriptions/s/bot" { + t.Errorf("BotServiceArmID = %q", req.BotServiceArmID) + } + if req.PublishScope != "Shared" { + t.Errorf("PublishScope = %q, want Shared", req.PublishScope) + } + if req.AgentDisplayName != "Contoso Helper" { + t.Errorf("AgentDisplayName = %q", req.AgentDisplayName) + } + if req.AppVersion != "1.0.0" { + t.Errorf("AppVersion = %q, want default 1.0.0", req.AppVersion) + } + if !req.CanRespondWithoutMention { + t.Error("CanRespondWithoutMention = false, want true") + } +} + +func TestTeamsAppDeepLink(t *testing.T) { + got := teamsAppDeepLink("abc-123") + want := "https://teams.microsoft.com/l/app/abc-123" + if got != want { + t.Errorf("teamsAppDeepLink = %q, want %q", got, want) + } +} + +func TestValidatePublishScope(t *testing.T) { + personal, _ := resolveTeamsPackScope("personal") + if err := validatePublishScope(personal); err == nil { + t.Error("expected personal scope to be rejected for publish, got nil") + } + for _, s := range []string{"shared", "org"} { + scope, _ := resolveTeamsPackScope(s) + if err := validatePublishScope(scope); err != nil { + t.Errorf("validatePublishScope(%q) = %v, want nil", s, err) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index bfe363d1b52..91d33c525a6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -123,6 +123,13 @@ const ( CodeBundledDepsNotFound = "bundled_deps_not_found" ) +// Error codes for Teams app pack/publish operations. +const ( + CodeNotActivityAgent = "not_activity_agent" + CodeAgentNotDeployed = "agent_not_deployed" + CodeInvalidPublishScope = "invalid_publish_scope" +) + // Error codes for $ref file-include resolution. const ( CodeInvalidFileRef = "invalid_file_ref" @@ -191,6 +198,8 @@ const ( OpCreateToolboxVersion = "create_toolbox_version" OpGetToolbox = "get_toolbox" OpProvisionMemoryStore = "provision_memory_store" + OpPackTeamsApp = "pack_teams_app" + OpPublishTeamsApp = "publish_teams_app" ) // Error codes for eval and optimize operations. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go index 78def375034..fabf059feb0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go @@ -5,6 +5,7 @@ package agent_api import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -94,3 +95,68 @@ func (c *AgentClient) DownloadTeamsAppPackage( } return zipBytes, nil } + +// TeamsAppPublishResult is the response of the Microsoft 365 "publish" endpoint. +// The V3 publish surface returns only the MOS title id and the generated Teams app +// id; the caller can build a Teams deep link (https://teams.microsoft.com/l/app/{TeamsAppID}) +// from TeamsAppID so a user (or anyone the app is shared with) can install it. +type TeamsAppPublishResult struct { + // TitleID is the MOS catalog title id for the published app. + TitleID string `json:"titleId"` + // TeamsAppID is the Teams app (external) id of the published custom engine agent. + TeamsAppID string `json:"teamsAppId"` +} + +// PublishTeamsApp calls the Microsoft 365 "publish" endpoint. Unlike the "zip" +// endpoint (which returns the package to the caller), the service rebuilds the same +// package internally and publishes it to the Microsoft Organization Store (MOS) +// under the requested scope, returning the resulting title id and Teams app id. +// +// The request body is the same TeamsAppPackageRequest used by the "zip" endpoint; +// only PublishScope drives the publish behavior. The call goes through the shared +// client pipeline so it inherits the same bearer-token, retry, and correlation +// policies as every other agent data-plane call, and a per-call timeout bounds the +// total wait. Any non-2xx response is returned as an error (via runtime.ResponseError) +// so the caller can surface the failure — publishing is an explicit user action, not +// a best-effort side effect, so tenant/permission failures are reported, not swallowed. +func (c *AgentClient) PublishTeamsApp( + ctx context.Context, + agentName string, + request TeamsAppPackageRequest, + apiVersion string, +) (*TeamsAppPublishResult, error) { + ctx, cancel := context.WithTimeout(ctx, 120*time.Second) + defer cancel() + + url := fmt.Sprintf("%s/agents/%s/microsoft365/publish?api-version=%s", c.endpoint, agentName, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodPost, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + if err := runtime.MarshalAsJSON(req, request); err != nil { + return nil, fmt.Errorf("failed to marshal Teams app publish request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview,AgentEndpoints=V1Preview") + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Teams app publish response: %w", err) + } + + var result TeamsAppPublishResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse Teams app publish response: %w", err) + } + return &result, nil +} From 86675fb2b7a435871889a3f42f40eb11f40b1f55 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:50:34 +0800 Subject: [PATCH 07/28] fix(ai.agents): align Teams publish scopes with service API Use tenant as the user-facing organization-wide publish scope, keep org as a compatibility alias, and keep publish output as pure JSON when -o json is requested. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../extensions/azure.ai.agents/internal/cmd/pack.go | 9 +++++---- .../azure.ai.agents/internal/cmd/publish.go | 10 ++++++---- .../azure.ai.agents/internal/cmd/teams_pack.go | 13 ++++++------- .../azure.ai.agents/internal/cmd/teams_pack_test.go | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go index a45605008c6..e268649a4a4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" @@ -159,12 +160,12 @@ func (a *PackAction) resolveOutputPath(packCtx *teamsPackContext) (string, error // joinScopeHelp renders the supported scope values with their one-line summaries // for the --scope flag help text. func joinScopeHelp() string { - out := "" + var out strings.Builder for i, scope := range teamsPackScopes { if i > 0 { - out += "; " + out.WriteString("; ") } - out += fmt.Sprintf("%s: %s", scope.flag, scope.summary) + out.WriteString(fmt.Sprintf("%s: %s", scope.flag, scope.summary)) } - return out + return out.String() } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go index 781e8691d66..e3d9c7e2691 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go @@ -39,7 +39,7 @@ requested scope, then returns the published title id and Teams app id. Scopes: shared shareable-link distribution (no tenant-admin approval required) [default] - org organization-wide catalog (requires IT-admin approval) + tenant organization-wide catalog (requires IT-admin approval) 'personal' is not supported here: per-user install is a Teams client action, not a store publish. For local testing, run 'azd ai agent pack' and sideload with @@ -55,7 +55,7 @@ failure rather than silently skipped.`, azd ai agent publish my-agent --scope shared # Publish organization-wide (requires IT-admin approval) - azd ai agent publish --scope org`, + azd ai agent publish --scope tenant`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -126,7 +126,9 @@ func (a *PublishAction) Run(ctx context.Context) error { appVersion: a.flags.appVersion, }) - fmt.Printf("Publishing Teams app for agent %q (scope: %s)...\n", packCtx.agentName, scope.flag) + if a.flags.output != "json" { + fmt.Printf("Publishing Teams app for agent %q (scope: %s)...\n", packCtx.agentName, scope.flag) + } result, err := packCtx.agentClient.PublishTeamsApp( ctx, packCtx.agentName, request, agent_api.Microsoft365APIVersion, @@ -157,7 +159,7 @@ func (a *PublishAction) Run(ctx context.Context) error { fmt.Printf(" Teams App ID: %s\n", result.TeamsAppID) fmt.Printf(" Install link: %s\n", deepLink) switch scope.flag { - case "org": + case "tenant": fmt.Println("The app is submitted to the organization catalog and awaits IT-admin approval.") default: fmt.Println("Share the install link above; recipients can add the app without tenant-admin approval.") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go index ba1f3b6ccea..731a824f6cf 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go @@ -32,12 +32,12 @@ type teamsPackScope struct { // teamsPackScopes enumerates the supported publish scopes. "personal" is the // default because it needs no Teams admin approval (per-user sideload); "shared" -// distributes via a shareable link without tenant-admin approval; "org" publishes -// to the whole organization and requires IT-admin approval. +// distributes via a shareable link without tenant-admin approval; "tenant" +// publishes to the whole organization and requires IT-admin approval. var teamsPackScopes = []teamsPackScope{ {flag: "personal", api: "Personal", summary: "per-user sideload (no admin approval required)"}, {flag: "shared", api: "Shared", summary: "shareable link distribution (no tenant-admin approval required)"}, - {flag: "org", api: "Tenant", summary: "organization-wide catalog (requires IT-admin approval)"}, + {flag: "tenant", api: "Tenant", summary: "organization-wide catalog (requires IT-admin approval)"}, } // resolveTeamsPackScope validates a user-supplied scope value and returns the @@ -49,10 +49,9 @@ func resolveTeamsPackScope(value string) (teamsPackScope, error) { if normalized == "" { normalized = "personal" } - // "tenant" is accepted as an alias for "org" because that is the underlying - // PublishScope value; users familiar with the service contract may reach for it. - if normalized == "tenant" { - normalized = "org" + // "org" is accepted as a friendly alias for the Vienna API's "Tenant" scope. + if normalized == "org" { + normalized = "tenant" } for _, scope := range teamsPackScopes { if scope.flag == normalized { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go index 2b48a3ade8c..045bac2490d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go @@ -89,7 +89,7 @@ func TestValidatePublishScope(t *testing.T) { if err := validatePublishScope(personal); err == nil { t.Error("expected personal scope to be rejected for publish, got nil") } - for _, s := range []string{"shared", "org"} { + for _, s := range []string{"shared", "tenant", "org"} { scope, _ := resolveTeamsPackScope(s) if err := validatePublishScope(scope); err != nil { t.Errorf("validatePublishScope(%q) = %v, want nil", s, err) From d3a1d4c29d755fd9c71ce51db5bec74136c67fc4 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:18:32 +0800 Subject: [PATCH 08/28] fix(ai.agents): use deployed agent name for Teams publishing Resolve the deployed agent name from AGENT__NAME for Teams bot binding, pack, and publish. The service key remains only the environment-variable key, so projects whose azure.yaml service name differs from the Foundry agent name route requests to the correct agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/listen_activity.go | 25 +++++++++++++++---- .../internal/cmd/teams_pack.go | 16 +++++++++--- 2 files changed, 32 insertions(+), 9 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 298d6ea6493..f2f6a1681ba 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 @@ -81,14 +81,30 @@ func ensureActivityBot( ) } + agentNameResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: fmt.Sprintf("AGENT_%s_NAME", serviceKey), + }) + if err != nil { + return fmt.Errorf("failed to read AGENT_%s_NAME for %q: %w", serviceKey, svc.Name, err) + } + if agentNameResp.Value == "" { + return fmt.Errorf( + "activity agent service %q has no recorded agent name yet; cannot bind the Teams bot. "+ + "Re-run 'azd deploy' once the agent is active.", + svc.Name, + ) + } + agentName := agentNameResp.Value + agentClient := agent_api.NewAgentClient(projectEndpoint, cred) versionObj, err := agentClient.GetAgentVersion( - ctx, svc.Name, versionResp.Value, DefaultAgentAPIVersion, + ctx, agentName, versionResp.Value, DefaultAgentAPIVersion, ) if err != nil { return fmt.Errorf( "failed to fetch agent version for %s/%s: %w", - svc.Name, versionResp.Value, err, + agentName, versionResp.Value, err, ) } @@ -116,9 +132,8 @@ func ensureActivityBot( 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 + // Use the deployed agent name recorded by deploy, which may differ from the + // azure.yaml service key. botName := botservice.BotName(agentName, botservice.BotScopeSalt(subscriptionID, resourceGroup)) cfg := botservice.BotConfig{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go index 731a824f6cf..2882a052d32 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go @@ -133,15 +133,23 @@ func resolveTeamsPackContext( } envName := envResp.Environment.Name - // The API agent name is the service name (deploy binds the bot with it), so the - // bot name derived below must use the same value. - agentName := svc.Name - // A recorded version is the signal that 'azd deploy' has run and created the bot // this Teams app binds to. Without it, packaging would reference a bot that does // not exist, so fail loudly with a clear next step instead of producing a broken // package. serviceKey := toServiceKey(svc.Name) + agentName, err := readEnvValue(ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_NAME", serviceKey)) + if err != nil { + return nil, err + } + if strings.TrimSpace(agentName) == "" { + return nil, exterrors.Dependency( + exterrors.CodeAgentNotDeployed, + fmt.Sprintf("agent service %q has not been deployed in environment %q", svc.Name, envName), + "run 'azd deploy' first; the deployed agent name is recorded during deploy and is required "+ + "before packaging or publishing", + ) + } version, err := readEnvValue(ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_VERSION", serviceKey)) if err != nil { return nil, err From 101f0b35f9a9a34d1d9a81114e0c494494deb527 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:14:37 +0800 Subject: [PATCH 09/28] fix(ai.agents): address Teams pack publish review feedback Align teardown with the deployed agent name, preserve package ownership semantics in pack, make publish scope help consistent, remove unused context fields, and drop the duplicate changelog entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../extensions/azure.ai.agents/CHANGELOG.md | 5 --- .../internal/cmd/listen_activity.go | 38 +++++++++++++++---- .../azure.ai.agents/internal/cmd/pack.go | 21 +++++++++- .../azure.ai.agents/internal/cmd/publish.go | 7 +++- .../internal/cmd/teams_pack.go | 13 ++----- 5 files changed, 60 insertions(+), 24 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 8050bcc971c..42fe3a4dccf 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,10 +1,5 @@ # Release History -## 1.0.0-beta.8 (Unreleased) - -### Features Added - -- [[#9332]](https://github.com/Azure/azure-dev/pull/9332) Add `azd ai agent pack` and `azd ai agent publish` for Activity (Teams) agents. `pack` downloads a ready-to-sideload Teams app package (`appPackage.zip`) from the Foundry Microsoft 365 service for local customization and `atk install --scope personal` sideloading; `publish` builds and publishes the package to the Microsoft 365 store, defaulting to `shared` scope (shareable install link, no tenant-admin approval) and supporting `org` scope (organization catalog, admin-gated). Both require a prior `azd deploy` and surface platform failures (tenant policy, permissions, service errors) as command failures rather than silently skipping. ## 1.0.0-beta.8 (2026-07-30) ### Features Added 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 f2f6a1681ba..c3ad9c4aca7 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 @@ -382,6 +382,21 @@ func printTeamsNextSteps(botName, msaAppID, guidePath, packagePath string) { // when it is missing or empty. func readEnvValue( ctx context.Context, azdClient *azdext.AzdClient, envName, key string, +) (string, error) { + value, err := readOptionalEnvValue(ctx, azdClient, envName, key) + if err != nil { + return "", err + } + if value == "" { + return "", fmt.Errorf("%s is not set in the environment", key) + } + return value, nil +} + +// readOptionalEnvValue reads an environment value, returning an empty string +// without error when it is missing or empty. +func readOptionalEnvValue( + ctx context.Context, azdClient *azdext.AzdClient, envName, key string, ) (string, error) { resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ EnvName: envName, @@ -390,9 +405,6 @@ func readEnvValue( 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 } @@ -403,7 +415,7 @@ func readEnvValue( func teardownActivityBots( ctx context.Context, azdClient *azdext.AzdClient, envName string, proj *azdext.ProjectConfig, ) { - var activityAgents []string + var activityServices []*azdext.ServiceConfig for _, svc := range proj.Services { if svc.Host != AiAgentHost { continue @@ -413,10 +425,10 @@ func teardownActivityBots( continue } if project.IsActivityProtocol(ca) { - activityAgents = append(activityAgents, svc.Name) + activityServices = append(activityServices, svc) } } - if len(activityAgents) == 0 { + if len(activityServices) == 0 { return } @@ -453,7 +465,19 @@ func teardownActivityBots( return } - for _, agentName := range activityAgents { + for _, svc := range activityServices { + agentName := svc.Name + serviceKey := toServiceKey(svc.Name) + if deployedName, err := readOptionalEnvValue( + ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_NAME", serviceKey), + ); err != nil { + log.Printf( + "postdown: using service name %q for Teams bot cleanup because AGENT_%s_NAME could not be read: %v", + svc.Name, serviceKey, err, + ) + } else if deployedName != "" { + agentName = deployedName + } 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) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go index e268649a4a4..c6e9209591c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go @@ -127,7 +127,20 @@ func (a *PackAction) Run(ctx context.Context) error { if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } - if err := os.WriteFile(outputPath, zipBytes, 0o600); err != nil { + if a.flags.outputDir == "" { + markerPath := filepath.Join(filepath.Dir(outputPath), teamsAppPackageMarkerFile) + written, err := commitTeamsAppPackage(outputPath, markerPath, zipBytes) + if err != nil { + return fmt.Errorf("failed to write Teams app package %q: %w", outputPath, err) + } + if written == "" { + return exterrors.Validation( + exterrors.CodeInvalidFilePath, + fmt.Sprintf("Teams app package %q already exists and was not generated by azd", outputPath), + "remove the existing file or use --output-dir to write the generated package elsewhere", + ) + } + } else if err := os.WriteFile(outputPath, zipBytes, 0o600); err != nil { return fmt.Errorf("failed to write Teams app package %q: %w", outputPath, err) } @@ -160,8 +173,12 @@ func (a *PackAction) resolveOutputPath(packCtx *teamsPackContext) (string, error // joinScopeHelp renders the supported scope values with their one-line summaries // for the --scope flag help text. func joinScopeHelp() string { + return joinScopesHelp(teamsPackScopes) +} + +func joinScopesHelp(scopes []teamsPackScope) string { var out strings.Builder - for i, scope := range teamsPackScopes { + for i, scope := range scopes { if i > 0 { out.WriteString("; ") } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go index e3d9c7e2691..aca58b43f65 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go @@ -15,6 +15,11 @@ import ( "github.com/spf13/cobra" ) +var teamsPublishScopes = []teamsPackScope{ + {flag: "shared", api: "Shared", summary: "shareable link distribution (no tenant-admin approval required)"}, + {flag: "tenant", api: "Tenant", summary: "organization-wide catalog (requires IT-admin approval; alias: org)"}, +} + type publishFlags struct { name string scope string @@ -71,7 +76,7 @@ failure rather than silently skipped.`, } cmd.Flags().StringVar(&flags.scope, "scope", "shared", - fmt.Sprintf("Publish scope (%s)", joinScopeHelp())) + fmt.Sprintf("Publish scope (%s)", joinScopesHelp(teamsPublishScopes))) cmd.Flags().StringVar(&flags.displayName, "display-name", "", "Display name for the Teams app (defaults to the agent name)") cmd.Flags().StringVar(&flags.appVersion, "app-version", "1.0.0", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go index 2882a052d32..a8e17708d74 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go @@ -14,7 +14,6 @@ import ( "azureaiagent/internal/pkg/botservice" "azureaiagent/internal/project" - "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) @@ -61,7 +60,7 @@ func resolveTeamsPackScope(value string) (teamsPackScope, error) { return teamsPackScope{}, exterrors.Validation( exterrors.CodeInvalidPublishScope, fmt.Sprintf("unsupported Teams publish scope %q", value), - fmt.Sprintf("use one of: %s", strings.Join(teamsPackScopeFlags(), ", ")), + fmt.Sprintf("use one of: %s (alias: org)", strings.Join(teamsPackScopeFlags(), ", ")), ) } @@ -83,12 +82,10 @@ func teamsPackScopeFlags() []string { // loudly when the agent is missing, is not an activity agent, or has not been // deployed yet. type teamsPackContext struct { - azdClient *azdext.AzdClient proj *azdext.ProjectConfig svc *azdext.ServiceConfig agentName string botArmID string - credential azcore.TokenCredential agentClient *agent_api.AgentClient } @@ -138,7 +135,7 @@ func resolveTeamsPackContext( // not exist, so fail loudly with a clear next step instead of producing a broken // package. serviceKey := toServiceKey(svc.Name) - agentName, err := readEnvValue(ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_NAME", serviceKey)) + agentName, err := readOptionalEnvValue(ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_NAME", serviceKey)) if err != nil { return nil, err } @@ -150,7 +147,7 @@ func resolveTeamsPackContext( "before packaging or publishing", ) } - version, err := readEnvValue(ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_VERSION", serviceKey)) + version, err := readOptionalEnvValue(ctx, azdClient, envName, fmt.Sprintf("AGENT_%s_VERSION", serviceKey)) if err != nil { return nil, err } @@ -185,12 +182,10 @@ func resolveTeamsPackContext( } return &teamsPackContext{ - azdClient: azdClient, proj: proj, svc: svc, agentName: agentName, botArmID: botArmID, - credential: credential, agentClient: agent_api.NewAgentClient(endpoint, credential), }, nil } @@ -251,7 +246,7 @@ func validatePublishScope(scope teamsPackScope) error { "'personal' scope is not supported by 'azd ai agent publish'", "personal install is a Teams client action, not a store publish; run "+ "'azd ai agent pack' and sideload with 'atk install --scope personal', "+ - "or publish with --scope shared or --scope org", + "or publish with --scope shared or --scope tenant", ) } return nil From cabba2367dce861985f0d960d09b6b6d0779c61e Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:56:28 +0800 Subject: [PATCH 10/28] Address Teams publish review feedback --- .../internal/cmd/listen_activity.go | 21 ++++++--- .../azure.ai.agents/internal/cmd/pack.go | 2 +- .../internal/cmd/teams_pack_test.go | 4 +- .../pkg/agents/agent_api/microsoft365_test.go | 45 +++++++++++++++++++ 4 files changed, 62 insertions(+), 10 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 c3ad9c4aca7..8efb3bdbdcd 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 @@ -252,13 +252,7 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str return "", nil } - tmpPath := packagePath + ".tmp" - if err := os.WriteFile(tmpPath, zipBytes, 0o600); err != nil { - _ = os.Remove(tmpPath) - return "", err - } - if err := os.Rename(tmpPath, packagePath); err != nil { - _ = os.Remove(tmpPath) + if err := writeTeamsAppPackageAtomically(packagePath, zipBytes); err != nil { return "", err } // Best-effort marker: if it can't be written the zip is still valid; azd just @@ -269,6 +263,19 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str return packagePath, nil } +func writeTeamsAppPackageAtomically(packagePath string, zipBytes []byte) error { + tmpPath := packagePath + ".tmp" + if err := os.WriteFile(tmpPath, zipBytes, 0o600); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, packagePath); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil +} + // removeOwnedTeamsAppPackage deletes a leftover azd-generated package (and its // marker) from a previous deploy so a failed or skipped packaging run cannot leave // a stale zip whose manifest and bot binding no longer match the current diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go index c6e9209591c..7308608d05f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go @@ -140,7 +140,7 @@ func (a *PackAction) Run(ctx context.Context) error { "remove the existing file or use --output-dir to write the generated package elsewhere", ) } - } else if err := os.WriteFile(outputPath, zipBytes, 0o600); err != nil { + } else if err := writeTeamsAppPackageAtomically(outputPath, zipBytes); err != nil { return fmt.Errorf("failed to write Teams app package %q: %w", outputPath, err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go index 045bac2490d..7390d60a4d9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go @@ -40,8 +40,8 @@ func TestResolveTeamsPackScopeInvalid(t *testing.T) { if err == nil { t.Fatal("expected error for unsupported scope, got nil") } - var localErr *azdext.LocalError - if !errors.As(err, &localErr) { + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { t.Fatalf("expected *azdext.LocalError, got %T", err) } if localErr.Code != exterrors.CodeInvalidPublishScope { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go index 3f5e6c18d05..d7cc62119c1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go @@ -56,3 +56,48 @@ func TestDownloadTeamsAppPackage_ErrorStatus(t *testing.T) { require.Contains(t, err.Error(), "403") require.Contains(t, err.Error(), "nope") } + +func TestPublishTeamsApp_Success(t *testing.T) { + client, transport := newCaptureClient( + http.StatusOK, + `{"titleId":"T_123","teamsAppId":"app-456"}`, + ) + + request := TeamsAppPackageRequest{ + BotServiceArmID: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.BotService/botServices/b", + PublishScope: "Shared", + AgentDisplayName: "my-agent", + AppVersion: "1.0.0", + CanRespondWithoutMention: true, + } + + result, err := client.PublishTeamsApp(t.Context(), "my-agent", request, Microsoft365APIVersion) + require.NoError(t, err) + require.Equal(t, "T_123", result.TitleID) + require.Equal(t, "app-456", result.TeamsAppID) + + require.Len(t, transport.requests, 1) + got := transport.requests[0] + require.Equal(t, http.MethodPost, got.Method) + require.Equal(t, "/api/projects/proj/agents/my-agent/microsoft365/publish", got.URL.Path) + require.Equal(t, Microsoft365APIVersion, got.URL.Query().Get("api-version")) + require.Equal(t, "application/json", got.Header.Get("Content-Type")) + require.Equal(t, "HostedAgents=V1Preview,AgentEndpoints=V1Preview", got.Header.Get("Foundry-Features")) + + bodyBytes, err := io.ReadAll(got.Body) + require.NoError(t, err) + var sent TeamsAppPackageRequest + require.NoError(t, json.Unmarshal(bodyBytes, &sent)) + require.Equal(t, request, sent) +} + +func TestPublishTeamsApp_ErrorStatus(t *testing.T) { + client, _ := newCaptureClient(http.StatusForbidden, `{"error":{"code":"Forbidden","message":"no publish"}}`) + + _, err := client.PublishTeamsApp( + t.Context(), "my-agent", TeamsAppPackageRequest{}, Microsoft365APIVersion, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "403") + require.Contains(t, err.Error(), "no publish") +} From c55a3335f148d26626e291e12a970d70d8836094 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:42:14 +0800 Subject: [PATCH 11/28] Use Teams title link for published apps --- .../azure.ai.agents/internal/cmd/publish.go | 2 +- .../azure.ai.agents/internal/cmd/teams_pack.go | 14 +++++++++----- .../internal/cmd/teams_pack_test.go | 4 ++-- .../internal/pkg/agents/agent_api/microsoft365.go | 4 ++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go index aca58b43f65..b5691387aa9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go @@ -142,7 +142,7 @@ func (a *PublishAction) Run(ctx context.Context) error { return exterrors.ServiceFromAzure(err, exterrors.OpPublishTeamsApp) } - deepLink := teamsAppDeepLink(result.TeamsAppID) + deepLink := teamsAppDeepLink(result.TitleID) if a.flags.output == "json" { payload := map[string]string{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go index a8e17708d74..307eadc7f36 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "net/url" "sort" "strings" @@ -228,11 +229,14 @@ func buildTeamsAppPackageRequest( } } -// teamsAppDeepLink returns the Teams deep link that installs the published custom -// engine agent. Anyone the app is shared with (for the "shared" scope) or the -// publishing user (for "personal") can open it to add the app in Teams. -func teamsAppDeepLink(teamsAppID string) string { - return fmt.Sprintf("https://teams.microsoft.com/l/app/%s", teamsAppID) +// teamsAppDeepLink returns the Teams v2 launcher link for a Microsoft Organization +// Store title. Shared-scope Foundry publishes are discoverable through the MOS +// title id; the Teams app id alone can open a catalog app only after acquisition. +func teamsAppDeepLink(titleID string) string { + return fmt.Sprintf( + "https://teams.microsoft.com/v2/#/l/app/?source=agent-details-page&titleId=%s&launchAgent=join_launcher_web", + url.QueryEscape(titleID), + ) } // validatePublishScope rejects scopes the Microsoft 365 publish backend does not diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go index 7390d60a4d9..c73db732390 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go @@ -77,8 +77,8 @@ func TestBuildTeamsAppPackageRequest(t *testing.T) { } func TestTeamsAppDeepLink(t *testing.T) { - got := teamsAppDeepLink("abc-123") - want := "https://teams.microsoft.com/l/app/abc-123" + got := teamsAppDeepLink("T_abc 123") + want := "https://teams.microsoft.com/v2/#/l/app/?source=agent-details-page&titleId=T_abc+123&launchAgent=join_launcher_web" if got != want { t.Errorf("teamsAppDeepLink = %q, want %q", got, want) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go index fabf059feb0..d10045c2247 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go @@ -98,8 +98,8 @@ func (c *AgentClient) DownloadTeamsAppPackage( // TeamsAppPublishResult is the response of the Microsoft 365 "publish" endpoint. // The V3 publish surface returns only the MOS title id and the generated Teams app -// id; the caller can build a Teams deep link (https://teams.microsoft.com/l/app/{TeamsAppID}) -// from TeamsAppID so a user (or anyone the app is shared with) can install it. +// id; the caller builds the install link from TitleID because shared-scope +// distribution is acquired through the MOS title before Teams can open the app id. type TeamsAppPublishResult struct { // TitleID is the MOS catalog title id for the published app. TitleID string `json:"titleId"` From 995e4a898a7174957249ff44cd52553968b11395 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:25:59 +0800 Subject: [PATCH 12/28] Use random temp file for Teams package writes --- .../internal/cmd/listen_activity.go | 22 +++++++++++++--- .../internal/cmd/listen_activity_test.go | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 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 8efb3bdbdcd..9076db347d1 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 @@ -264,15 +264,29 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str } func writeTeamsAppPackageAtomically(packagePath string, zipBytes []byte) error { - tmpPath := packagePath + ".tmp" - if err := os.WriteFile(tmpPath, zipBytes, 0o600); err != nil { - _ = os.Remove(tmpPath) + tmpFile, err := os.CreateTemp(filepath.Dir(packagePath), filepath.Base(packagePath)+".*.tmp") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + removeTemp := true + defer func() { + if removeTemp { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(zipBytes); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { return err } if err := os.Rename(tmpPath, packagePath); err != nil { - _ = os.Remove(tmpPath) return err } + removeTemp = false return nil } 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 b10694dcf92..89ae64c9689 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 @@ -129,6 +129,32 @@ func TestCommitTeamsAppPackage_WritesWhenAbsent(t *testing.T) { } } +func TestCommitTeamsAppPackage_DoesNotUseFixedTempPath(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + fixedTemp := pkg + ".tmp" + if err := os.WriteFile(fixedTemp, []byte("DO-NOT-CLOBBER"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := commitTeamsAppPackage(pkg, marker, []byte("AZD-GENERATED")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != pkg { + t.Errorf("path = %q, want %q", got, pkg) + } + data, _ := os.ReadFile(pkg) + if string(data) != "AZD-GENERATED" { + t.Errorf("package content = %q", string(data)) + } + tempData, _ := os.ReadFile(fixedTemp) + if string(tempData) != "DO-NOT-CLOBBER" { + t.Errorf("fixed temp path was clobbered; content = %q", string(tempData)) + } +} + func TestCommitTeamsAppPackage_OverwritesOwned(t *testing.T) { dir := t.TempDir() pkg := filepath.Join(dir, teamsAppPackageFile) From 6782c29bce4a7f3a56ac86c58c1ceed8d84439bb Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:40:26 +0800 Subject: [PATCH 13/28] Harden Teams package artifact writes --- .../internal/cmd/listen_activity.go | 24 ++++++++++--- .../internal/cmd/listen_activity_test.go | 35 +++++++++++++++++++ .../pkg/agents/agent_api/microsoft365.go | 3 ++ .../pkg/agents/agent_api/microsoft365_test.go | 10 ++++++ 4 files changed, 67 insertions(+), 5 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 9076db347d1..60741d109ec 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 @@ -234,7 +234,17 @@ func writeTeamsAppPackage( // teamsAppPackageIsOwned reports whether appPackage.zip was generated by azd, as // recorded by the sidecar marker. azd only removes or overwrites packages it owns. func teamsAppPackageIsOwned(markerPath string) bool { - _, err := os.Stat(markerPath) + info, err := os.Lstat(markerPath) + if err != nil { + return false + } + if info.Mode()&os.ModeSymlink != 0 { + log.Printf("postdeploy: ownership marker %q is a symlink; treating package as user-owned", markerPath) + return false + } + if info.IsDir() { + return false + } return err == nil } @@ -257,14 +267,18 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str } // Best-effort marker: if it can't be written the zip is still valid; azd just // won't recognize the file as its own on a later run. - if err := os.WriteFile(markerPath, []byte("generated by azd\n"), 0o600); err != nil { + if err := writeTeamsAppFileAtomically(markerPath, []byte("generated by azd\n")); err != nil { log.Printf("postdeploy: could not write Teams app package ownership marker %q: %v", markerPath, err) } return packagePath, nil } func writeTeamsAppPackageAtomically(packagePath string, zipBytes []byte) error { - tmpFile, err := os.CreateTemp(filepath.Dir(packagePath), filepath.Base(packagePath)+".*.tmp") + return writeTeamsAppFileAtomically(packagePath, zipBytes) +} + +func writeTeamsAppFileAtomically(path string, data []byte) error { + tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") if err != nil { return err } @@ -276,14 +290,14 @@ func writeTeamsAppPackageAtomically(packagePath string, zipBytes []byte) error { } }() - if _, err := tmpFile.Write(zipBytes); err != nil { + if _, err := tmpFile.Write(data); err != nil { _ = tmpFile.Close() return err } if err := tmpFile.Close(); err != nil { return err } - if err := os.Rename(tmpPath, packagePath); err != nil { + if err := os.Rename(tmpPath, path); err != nil { return err } removeTemp = false 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 89ae64c9689..7b87a3ac3c9 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 @@ -155,6 +155,41 @@ func TestCommitTeamsAppPackage_DoesNotUseFixedTempPath(t *testing.T) { } } +func TestCommitTeamsAppPackage_DoesNotUseFixedMarkerTempPath(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + fixedTemp := marker + ".tmp" + if err := os.WriteFile(fixedTemp, []byte("DO-NOT-CLOBBER"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := commitTeamsAppPackage(pkg, marker, []byte("AZD-GENERATED")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tempData, _ := os.ReadFile(fixedTemp) + if string(tempData) != "DO-NOT-CLOBBER" { + t.Errorf("fixed marker temp path was clobbered; content = %q", string(tempData)) + } +} + +func TestTeamsAppPackageIsOwned_RejectsSymlinkMarker(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(target, []byte("not an azd marker"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, marker); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + if teamsAppPackageIsOwned(marker) { + t.Fatal("symlink marker must not mark a package as azd-owned") + } +} + func TestCommitTeamsAppPackage_OverwritesOwned(t *testing.T) { dir := t.TempDir() pkg := filepath.Join(dir, teamsAppPackageFile) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go index d10045c2247..613e883cbdd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go @@ -93,6 +93,9 @@ func (c *AgentClient) DownloadTeamsAppPackage( if err != nil { return nil, fmt.Errorf("failed to read Teams app package response: %w", err) } + if len(zipBytes) == 0 { + return nil, fmt.Errorf("Teams app package response was empty") + } return zipBytes, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go index d7cc62119c1..2f535634974 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go @@ -57,6 +57,16 @@ func TestDownloadTeamsAppPackage_ErrorStatus(t *testing.T) { require.Contains(t, err.Error(), "nope") } +func TestDownloadTeamsAppPackage_EmptyBody(t *testing.T) { + client := newTestClient("https://example.test/api/projects/proj", &fakeTransport{statusCode: http.StatusOK}) + + _, err := client.DownloadTeamsAppPackage( + t.Context(), "my-agent", TeamsAppPackageRequest{}, Microsoft365APIVersion, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "empty") +} + func TestPublishTeamsApp_Success(t *testing.T) { client, transport := newCaptureClient( http.StatusOK, From 2f2a7bb976c071098f5a1b0a61e0c810eee2cf15 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:51:58 +0800 Subject: [PATCH 14/28] Fail Teams package commit when marker write fails Treat ownership marker write failures as package commit failures so retries cannot leave an unowned generated package behind. Roll back newly created packages on marker failure while preserving packages that already existed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/listen_activity.go | 17 +++++++++++++---- .../internal/cmd/listen_activity_test.go | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 4 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 60741d109ec..fa3f36256bd 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 @@ -254,7 +254,13 @@ func teamsAppPackageIsOwned(markerPath string) bool { // own manually assembled or downloaded zip), it is preserved untouched and "" is // returned so the manual guide is used instead of clobbering the user's file. func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (string, error) { - if _, err := os.Stat(packagePath); err == nil && !teamsAppPackageIsOwned(markerPath) { + packageExisted := false + if _, err := os.Stat(packagePath); err == nil { + packageExisted = true + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + if packageExisted && !teamsAppPackageIsOwned(markerPath) { log.Printf( "postdeploy: %q already exists and was not generated by azd; leaving it untouched", packagePath, @@ -265,10 +271,13 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str if err := writeTeamsAppPackageAtomically(packagePath, zipBytes); err != nil { return "", err } - // Best-effort marker: if it can't be written the zip is still valid; azd just - // won't recognize the file as its own on a later run. if err := writeTeamsAppFileAtomically(markerPath, []byte("generated by azd\n")); err != nil { - log.Printf("postdeploy: could not write Teams app package ownership marker %q: %v", markerPath, err) + if !packageExisted { + if removeErr := os.Remove(packagePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + log.Printf("postdeploy: could not roll back Teams app package %q after marker write failed: %v", packagePath, removeErr) + } + } + return "", fmt.Errorf("write Teams app package ownership marker %q: %w", markerPath, err) } return packagePath, nil } 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 7b87a3ac3c9..92005018d94 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 @@ -129,6 +129,23 @@ func TestCommitTeamsAppPackage_WritesWhenAbsent(t *testing.T) { } } +func TestCommitTeamsAppPackage_RollsBackNewPackageWhenMarkerWriteFails(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, "missing", teamsAppPackageMarkerFile) + + got, err := commitTeamsAppPackage(pkg, marker, []byte("AZD-GENERATED")) + if err == nil { + t.Fatal("expected marker write error") + } + if got != "" { + t.Errorf("path = %q, want empty path on failure", got) + } + if _, err := os.Stat(pkg); !os.IsNotExist(err) { + t.Errorf("new package must be rolled back when marker write fails") + } +} + func TestCommitTeamsAppPackage_DoesNotUseFixedTempPath(t *testing.T) { dir := t.TempDir() pkg := filepath.Join(dir, teamsAppPackageFile) From 2317be5eed958cc620866ed4e2d2a2e2142dd3c4 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:57:59 +0800 Subject: [PATCH 15/28] Harden Teams publish responses and package ignores Reject publish responses that are empty or missing titleId so the command does not print an unusable Teams link. Always exclude generated Teams package artifacts from code deploy packaging, even when existing projects have a custom .agentignore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../pkg/agents/agent_api/microsoft365.go | 6 +++++ .../pkg/agents/agent_api/microsoft365_test.go | 24 +++++++++++++++++++ .../internal/project/agentignore.go | 16 +++++++++++++ .../internal/project/agentignore_test.go | 22 +++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go index 613e883cbdd..ae519cad64d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go @@ -156,10 +156,16 @@ func (c *AgentClient) PublishTeamsApp( if err != nil { return nil, fmt.Errorf("failed to read Teams app publish response: %w", err) } + if len(body) == 0 { + return nil, fmt.Errorf("Teams app publish response was empty") + } var result TeamsAppPublishResult if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("failed to parse Teams app publish response: %w", err) } + if result.TitleID == "" { + return nil, fmt.Errorf("Teams app publish response was missing titleId") + } return &result, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go index 2f535634974..60b4445eb6e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go @@ -111,3 +111,27 @@ func TestPublishTeamsApp_ErrorStatus(t *testing.T) { require.Contains(t, err.Error(), "403") require.Contains(t, err.Error(), "no publish") } + +func TestPublishTeamsApp_EmptyBody(t *testing.T) { + client := newTestClient("https://example.test/api/projects/proj", &fakeTransport{statusCode: http.StatusOK}) + + _, err := client.PublishTeamsApp( + t.Context(), "my-agent", TeamsAppPackageRequest{}, Microsoft365APIVersion, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "empty") +} + +func TestPublishTeamsApp_MissingTitleID(t *testing.T) { + for _, body := range []string{`{}`, `null`, `{"teamsAppId":"app-456"}`} { + t.Run(body, func(t *testing.T) { + client, _ := newCaptureClient(http.StatusOK, body) + + _, err := client.PublishTeamsApp( + t.Context(), "my-agent", TeamsAppPackageRequest{}, Microsoft365APIVersion, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "titleId") + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go index 2d79b3db354..dd785da8ed2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go @@ -25,6 +25,14 @@ const ( // Generated from DefaultAgentIgnoreContent() to maintain a single source of truth. var defaultExclusionsContent = DefaultAgentIgnoreContent() +// alwaysExcludedFromCodeDeploy are azd-generated artifacts that are never part of +// the agent source code, even when an existing project has a custom .agentignore. +var alwaysExcludedFromCodeDeploy = map[string]struct{}{ + "appPackage.zip": {}, + ".appPackage.zip.azd-generated": {}, + "TEAMS_APP_SETUP.md": {}, +} + // utf8BOM is the byte order mark that some Windows editors prepend to UTF-8 files. var utf8BOM = []byte{0xEF, 0xBB, 0xBF} @@ -65,6 +73,11 @@ func newAgentIgnoreMatcher(ctx context.Context, srcDir string) (*agentIgnoreMatc // relPath is the path relative to srcDir using forward slashes. // isDir indicates whether the path is a directory. func (m *agentIgnoreMatcher) ShouldExclude(relPath string, isDir bool) bool { + if !isDir { + if _, ok := alwaysExcludedFromCodeDeploy[relPath]; ok { + return true + } + } match := m.ignore.Relative(relPath, isDir) if match != nil && match.Ignore() { return true @@ -125,6 +138,9 @@ agent.yaml agent.manifest.yaml azure.yaml .agentignore +appPackage.zip +.appPackage.zip.azd-generated +TEAMS_APP_SETUP.md # Security / secrets .env diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go index 54a96448b45..6934eeee6d7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go @@ -72,6 +72,25 @@ func TestAgentIgnore_UserFileOverridesDefaults(t *testing.T) { require.False(t, m.ShouldExclude(".git", true)) } +func TestAgentIgnore_AlwaysExcludesGeneratedTeamsArtifacts(t *testing.T) { + for _, name := range []string{ + "appPackage.zip", + ".appPackage.zip.azd-generated", + "TEAMS_APP_SETUP.md", + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(""), 0600) + require.NoError(t, err) + + m, err := newAgentIgnoreMatcher(t.Context(), dir) + require.NoError(t, err) + require.True(t, m.hasUserIgnore) + require.True(t, m.ShouldExclude(name, false)) + }) + } +} + func TestAgentIgnore_NegationWorks(t *testing.T) { dir := t.TempDir() // Exclude all .txt but keep important.txt @@ -143,4 +162,7 @@ func TestDefaultAgentIgnoreContent(t *testing.T) { require.Contains(t, content, ".git/") require.Contains(t, content, "Dockerfile") require.Contains(t, content, ".dockerignore") + require.Contains(t, content, "appPackage.zip") + require.Contains(t, content, ".appPackage.zip.azd-generated") + require.Contains(t, content, "TEAMS_APP_SETUP.md") } From 8ee71e47c37e7c2ad46c28e654d55d028e482398 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:08:06 +0800 Subject: [PATCH 16/28] Apply agent name flag to adopted azure.yaml samples Honor --agent-name when initializing from a unified azure.yaml sample by writing the chosen name back to the adopted agent service before existing-agent conflict checks run. Also pin the interactive unified-template name so the adopted project uses the selected agent identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../azure.ai.agents/internal/cmd/init.go | 1 + .../internal/cmd/init_adopt.go | 75 ++++++++++++++++++- .../internal/cmd/init_adopt_test.go | 63 +++++++++++++++- 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 884ca71f428..83ecd8c3a5a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1441,6 +1441,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } return err } + flags.agentName = resolvedName if flags.src == "" && resolvedName != "" { flags.src = sanitizeAgentName(resolvedName) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 858ba630703..1f4965cc8ef 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -656,7 +656,7 @@ func updateAzureYamlDeployments( type adoptedAgentNameResolver func(context.Context, string) (string, error) func adoptedAgentNameConflictSuggestion() string { - return "To create a separate agent, update the agent service's `name` in the adopted azure.yaml before deploying.\n" + return "To create a separate agent, re-run init with --agent-name .\n" } // confirmAdoptedAgentNameConflicts checks every agent definition embedded in an @@ -734,6 +734,60 @@ func updateAdoptedAgentNames( return nil } +func applyAdoptedAgentNameOverride( + ctx context.Context, + azdClient *azdext.AzdClient, + agentName string, +) error { + agentName, err := validateInitAgentName(agentName) + if err != nil { + return err + } + + resp, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("reading adopted project for agent name override: %w", err) + } + + var serviceName string + var configPath string + for name, svc := range resp.GetProject().GetServices() { + if svc.GetHost() != AiAgentHost { + continue + } + currentName, path := adoptedAgentNameConfig(svc) + if currentName == "" { + continue + } + if serviceName != "" { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name cannot be applied to an adopted azure.yaml with multiple agent services", + "update each agent service's `name` in azure.yaml after init, or use a sample with a single agent service", + ) + } + serviceName = name + configPath = path + } + if serviceName == "" { + return nil + } + + value, err := structpb.NewValue(agentName) + if err != nil { + return fmt.Errorf("encoding agent name override for agent service %q: %w", serviceName, err) + } + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: configPath, + Value: value, + }); err != nil { + return fmt.Errorf("updating agent name in adopted azure.yaml for service %q: %w", serviceName, err) + } + + return nil +} + // adoptedAgentNameConfig returns the Foundry agent name and its service-relative // config path for the unified inline shape or deprecated config-nested shape. func adoptedAgentNameConfig(svc *azdext.ServiceConfig) (string, string) { @@ -832,7 +886,19 @@ func runInitFromAzureYaml( httpClient *http.Client, content []byte, ) error { - targetDir, folderDisplay := adoptTargetDir(flags, foundryProjectName(content)) + projectName := foundryProjectName(content) + agentNameOverride := strings.TrimSpace(flags.agentName) + if agentNameOverride != "" { + validatedName, err := validateInitAgentName(agentNameOverride) + if err != nil { + return err + } + flags.agentName = validatedName + agentNameOverride = validatedName + projectName = validatedName + } + + targetDir, folderDisplay := adoptTargetDir(flags, projectName) // Adoption is a fresh-project operation: it lays down the project-root // azure.yaml. When the target already contains an azd project manifest we @@ -870,6 +936,11 @@ func runInitFromAzureYaml( if err := ensureFoundryProviderDeclared(ctx, azdClient); err != nil { return err } + if agentNameOverride != "" { + if err := applyAdoptedAgentNameOverride(ctx, azdClient, agentNameOverride); err != nil { + return err + } + } // --- Interactive Azure context setup (subscription, Foundry project) --- // The scaffolding created an environment; load it and run the same Foundry diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index aa697dbd69c..f895cf10969 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -641,8 +641,7 @@ func TestAdoptedAgentNameConflictSuggestion(t *testing.T) { t.Parallel() suggestion := adoptedAgentNameConflictSuggestion() - require.Contains(t, suggestion, "adopted azure.yaml") - require.NotContains(t, suggestion, "--agent-name") + require.Contains(t, suggestion, "--agent-name") } func newAdoptedAgentNameTestClient( @@ -802,6 +801,66 @@ func TestUpdateAdoptedAgentNames_UnchangedNamesAreNotWritten(t *testing.T) { require.Empty(t, server.configValues) } +func TestApplyAdoptedAgentNameOverride_PersistsFlagName(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "agent-service": { + Name: "agent-service", + Host: AiAgentHost, + AdditionalProperties: &structpb.Struct{Fields: map[string]*structpb.Value{ + "kind": structpb.NewStringValue("hosted"), + "name": structpb.NewStringValue("echo-activity"), + }}, + }, + }, + } + client := newProjectRecorderClient(t, server) + + err := applyAdoptedAgentNameOverride(t.Context(), client, "test0804") + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + require.Equal(t, "agent-service", server.configValues["name"].serviceName) + require.Equal(t, "test0804", server.configValues["name"].value) +} + +func TestApplyAdoptedAgentNameOverride_RejectsMultipleAgents(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "agent-a": { + Name: "agent-a", + Host: AiAgentHost, + AdditionalProperties: &structpb.Struct{Fields: map[string]*structpb.Value{ + "kind": structpb.NewStringValue("hosted"), + "name": structpb.NewStringValue("agent-a"), + }}, + }, + "agent-b": { + Name: "agent-b", + Host: AiAgentHost, + AdditionalProperties: &structpb.Struct{Fields: map[string]*structpb.Value{ + "kind": structpb.NewStringValue("hosted"), + "name": structpb.NewStringValue("agent-b"), + }}, + }, + }, + } + client := newProjectRecorderClient(t, server) + + err := applyAdoptedAgentNameOverride(t.Context(), client, "test0804") + require.Error(t, err) + require.Contains(t, err.Error(), "multiple agent services") + + server.mu.Lock() + defer server.mu.Unlock() + require.Empty(t, server.configValues) +} + // TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint // writes the endpoint to the existing azure.ai.project service via // SetServiceConfigValue when a valid project is provided. From 1c58582f1cd6f215e504ed036e0d00b6e2de02d3 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:22:43 +0800 Subject: [PATCH 17/28] Wrap Teams package rollback log line Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../azure.ai.agents/internal/cmd/listen_activity.go | 6 +++++- 1 file changed, 5 insertions(+), 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 fa3f36256bd..72b7c931957 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 @@ -274,7 +274,11 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str if err := writeTeamsAppFileAtomically(markerPath, []byte("generated by azd\n")); err != nil { if !packageExisted { if removeErr := os.Remove(packagePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { - log.Printf("postdeploy: could not roll back Teams app package %q after marker write failed: %v", packagePath, removeErr) + log.Printf( + "postdeploy: could not roll back Teams app package %q after marker write failed: %v", + packagePath, + removeErr, + ) } } return "", fmt.Errorf("write Teams app package ownership marker %q: %w", markerPath, err) From e3d7d281f81e4de4615f3078895746c5116a185a Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:33:57 +0800 Subject: [PATCH 18/28] Address adopted agent names and Teams artifact ignores Make adopted azure.yaml agent-name guidance actionable for single- and multi-agent samples, fail when --agent-name cannot be applied to a named agent service, preserve .agentignore negation semantics for generated Teams artifacts, and clarify pack/publish output by showing the Teams app display name separately from the Foundry agent name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/init_adopt.go | 9 +++++-- .../internal/cmd/init_adopt_test.go | 26 +++++++++++++++++++ .../azure.ai.agents/internal/cmd/pack.go | 7 ++++- .../azure.ai.agents/internal/cmd/publish.go | 18 ++++++++----- .../internal/project/agentignore.go | 18 +++++-------- .../internal/project/agentignore_test.go | 23 +++++++++++++++- 6 files changed, 79 insertions(+), 22 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 1f4965cc8ef..6cf32240037 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -656,7 +656,8 @@ func updateAzureYamlDeployments( type adoptedAgentNameResolver func(context.Context, string) (string, error) func adoptedAgentNameConflictSuggestion() string { - return "To create a separate agent, re-run init with --agent-name .\n" + return "To create a separate agent, re-run init with --agent-name for single-agent samples, " + + "or update each agent service's `name` in the adopted azure.yaml.\n" } // confirmAdoptedAgentNameConflicts checks every agent definition embedded in an @@ -770,7 +771,11 @@ func applyAdoptedAgentNameOverride( configPath = path } if serviceName == "" { - return nil + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name could not be applied because the adopted azure.yaml has no named agent service", + "update the agent service's `name` in azure.yaml after init, or use a sample with a named agent service", + ) } value, err := structpb.NewValue(agentName) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index f895cf10969..9c32dbe50c1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -642,6 +642,7 @@ func TestAdoptedAgentNameConflictSuggestion(t *testing.T) { suggestion := adoptedAgentNameConflictSuggestion() require.Contains(t, suggestion, "--agent-name") + require.Contains(t, suggestion, "adopted azure.yaml") } func newAdoptedAgentNameTestClient( @@ -861,6 +862,31 @@ func TestApplyAdoptedAgentNameOverride_RejectsMultipleAgents(t *testing.T) { require.Empty(t, server.configValues) } +func TestApplyAdoptedAgentNameOverride_RejectsNoNamedAgent(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "agent-service": { + Name: "agent-service", + Host: AiAgentHost, + AdditionalProperties: &structpb.Struct{Fields: map[string]*structpb.Value{ + "kind": structpb.NewStringValue("hosted"), + }}, + }, + }, + } + client := newProjectRecorderClient(t, server) + + err := applyAdoptedAgentNameOverride(t.Context(), client, "test0804") + require.Error(t, err) + require.Contains(t, err.Error(), "no named agent service") + + server.mu.Lock() + defer server.mu.Unlock() + require.Empty(t, server.configValues) +} + // TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint // writes the endpoint to the existing azure.ai.project service via // SetServiceConfigValue when a valid project is provided. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go index 7308608d05f..6f647e99fe2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go @@ -115,7 +115,12 @@ func (a *PackAction) Run(ctx context.Context) error { return err } - fmt.Printf("Packing Teams app for agent %q (scope: %s)...\n", packCtx.agentName, scope.flag) + fmt.Printf( + "Packing Teams app %q for agent %q (scope: %s)...\n", + displayName, + packCtx.agentName, + scope.flag, + ) zipBytes, err := packCtx.agentClient.DownloadTeamsAppPackage( ctx, packCtx.agentName, request, agent_api.Microsoft365APIVersion, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go index b5691387aa9..adfa16c9bd7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go @@ -132,7 +132,12 @@ func (a *PublishAction) Run(ctx context.Context) error { }) if a.flags.output != "json" { - fmt.Printf("Publishing Teams app for agent %q (scope: %s)...\n", packCtx.agentName, scope.flag) + fmt.Printf( + "Publishing Teams app %q for agent %q (scope: %s)...\n", + displayName, + packCtx.agentName, + scope.flag, + ) } result, err := packCtx.agentClient.PublishTeamsApp( @@ -146,10 +151,11 @@ func (a *PublishAction) Run(ctx context.Context) error { if a.flags.output == "json" { payload := map[string]string{ - "titleId": result.TitleID, - "teamsAppId": result.TeamsAppID, - "scope": scope.flag, - "deepLink": deepLink, + "titleId": result.TitleID, + "teamsAppId": result.TeamsAppID, + "scope": scope.flag, + "displayName": displayName, + "deepLink": deepLink, } data, jsonErr := json.MarshalIndent(payload, "", " ") if jsonErr != nil { @@ -159,7 +165,7 @@ func (a *PublishAction) Run(ctx context.Context) error { return nil } - fmt.Printf("Published Teams app for agent %q (scope: %s)\n", packCtx.agentName, scope.flag) + fmt.Printf("Published Teams app %q for agent %q (scope: %s)\n", displayName, packCtx.agentName, scope.flag) fmt.Printf(" Title ID: %s\n", result.TitleID) fmt.Printf(" Teams App ID: %s\n", result.TeamsAppID) fmt.Printf(" Install link: %s\n", deepLink) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go index dd785da8ed2..5aab095705f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore.go @@ -25,13 +25,11 @@ const ( // Generated from DefaultAgentIgnoreContent() to maintain a single source of truth. var defaultExclusionsContent = DefaultAgentIgnoreContent() -// alwaysExcludedFromCodeDeploy are azd-generated artifacts that are never part of -// the agent source code, even when an existing project has a custom .agentignore. -var alwaysExcludedFromCodeDeploy = map[string]struct{}{ - "appPackage.zip": {}, - ".appPackage.zip.azd-generated": {}, - "TEAMS_APP_SETUP.md": {}, -} +const generatedTeamsArtifactsIgnoreContent = `# azd-generated Teams app artifacts +appPackage.zip +.appPackage.zip.azd-generated +TEAMS_APP_SETUP.md +` // utf8BOM is the byte order mark that some Windows editors prepend to UTF-8 files. var utf8BOM = []byte{0xEF, 0xBB, 0xBF} @@ -73,11 +71,6 @@ func newAgentIgnoreMatcher(ctx context.Context, srcDir string) (*agentIgnoreMatc // relPath is the path relative to srcDir using forward slashes. // isDir indicates whether the path is a directory. func (m *agentIgnoreMatcher) ShouldExclude(relPath string, isDir bool) bool { - if !isDir { - if _, ok := alwaysExcludedFromCodeDeploy[relPath]; ok { - return true - } - } match := m.ignore.Relative(relPath, isDir) if match != nil && match.Ignore() { return true @@ -120,6 +113,7 @@ func loadAgentIgnore(ctx context.Context, srcDir string) (gitignore.GitIgnore, e // Strip UTF-8 BOM data = bytes.TrimPrefix(data, utf8BOM) + data = append([]byte(generatedTeamsArtifactsIgnoreContent+"\n"), data...) return gitignore.New(bytes.NewReader(data), srcDir, nil), nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go index 6934eeee6d7..b13838b3dd5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agentignore_test.go @@ -75,8 +75,11 @@ func TestAgentIgnore_UserFileOverridesDefaults(t *testing.T) { func TestAgentIgnore_AlwaysExcludesGeneratedTeamsArtifacts(t *testing.T) { for _, name := range []string{ "appPackage.zip", + "build/appPackage.zip", ".appPackage.zip.azd-generated", + "build/.appPackage.zip.azd-generated", "TEAMS_APP_SETUP.md", + "build/TEAMS_APP_SETUP.md", } { t.Run(name, func(t *testing.T) { dir := t.TempDir() @@ -91,6 +94,22 @@ func TestAgentIgnore_AlwaysExcludesGeneratedTeamsArtifacts(t *testing.T) { } } +func TestAgentIgnore_GeneratedTeamsArtifactsCanBeNegated(t *testing.T) { + dir := t.TempDir() + content := "!appPackage.zip\n!TEAMS_APP_SETUP.md\n" + err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(content), 0600) + require.NoError(t, err) + + m, err := newAgentIgnoreMatcher(t.Context(), dir) + require.NoError(t, err) + require.True(t, m.hasUserIgnore) + require.False(t, m.ShouldExclude("appPackage.zip", false)) + require.False(t, m.ShouldExclude("build/appPackage.zip", false)) + require.False(t, m.ShouldExclude("TEAMS_APP_SETUP.md", false)) + require.False(t, m.ShouldExclude("build/TEAMS_APP_SETUP.md", false)) + require.True(t, m.ShouldExclude(".appPackage.zip.azd-generated", false)) +} + func TestAgentIgnore_NegationWorks(t *testing.T) { dir := t.TempDir() // Exclude all .txt but keep important.txt @@ -144,10 +163,12 @@ func TestAgentIgnore_EmptyFile(t *testing.T) { require.NoError(t, err) require.True(t, m.hasUserIgnore) - // Nothing excluded — empty file means include everything + // Defaults no longer apply, but azd-generated Teams artifacts are prepended + // to avoid bundling them into code deploy packages. require.False(t, m.ShouldExclude("main.py", false)) require.False(t, m.ShouldExclude("__pycache__", true)) require.False(t, m.ShouldExclude(".env", false)) + require.True(t, m.ShouldExclude("appPackage.zip", false)) } func TestDefaultAgentIgnoreContent(t *testing.T) { From ea03d7123052561949c54f21c10143bc69f264c8 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:54:51 +0800 Subject: [PATCH 19/28] Track Teams package ownership by digest Record the generated appPackage.zip SHA-256 in the ownership marker and only overwrite or remove the package when the current ZIP still matches that digest. Customized generated packages are now treated as user-owned and preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/listen_activity.go | 30 ++++++-- .../internal/cmd/listen_activity_test.go | 72 +++++++++++++++++-- 2 files changed, 91 insertions(+), 11 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 72b7c931957..f39a22c8e2a 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 @@ -6,6 +6,7 @@ package cmd import ( "bytes" "context" + "crypto/sha256" _ "embed" "errors" "fmt" @@ -231,9 +232,10 @@ func writeTeamsAppPackage( return written } -// teamsAppPackageIsOwned reports whether appPackage.zip was generated by azd, as -// recorded by the sidecar marker. azd only removes or overwrites packages it owns. -func teamsAppPackageIsOwned(markerPath string) bool { +// teamsAppPackageIsOwned reports whether appPackage.zip is still the same file +// azd generated, as recorded by the sidecar digest marker. azd only removes or +// overwrites packages it owns and that users have not customized. +func teamsAppPackageIsOwned(packagePath, markerPath string) bool { info, err := os.Lstat(markerPath) if err != nil { return false @@ -245,7 +247,21 @@ func teamsAppPackageIsOwned(markerPath string) bool { if info.IsDir() { return false } - return err == nil + + markerBytes, err := os.ReadFile(markerPath) + if err != nil { + return false + } + packageBytes, err := os.ReadFile(packagePath) + if err != nil { + return false + } + + return string(markerBytes) == teamsAppPackageMarkerContent(packageBytes) +} + +func teamsAppPackageMarkerContent(zipBytes []byte) string { + return fmt.Sprintf("sha256:%x\n", sha256.Sum256(zipBytes)) } // commitTeamsAppPackage writes the generated package atomically (temp file + @@ -260,7 +276,7 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str } else if !errors.Is(err, os.ErrNotExist) { return "", err } - if packageExisted && !teamsAppPackageIsOwned(markerPath) { + if packageExisted && !teamsAppPackageIsOwned(packagePath, markerPath) { log.Printf( "postdeploy: %q already exists and was not generated by azd; leaving it untouched", packagePath, @@ -271,7 +287,7 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str if err := writeTeamsAppPackageAtomically(packagePath, zipBytes); err != nil { return "", err } - if err := writeTeamsAppFileAtomically(markerPath, []byte("generated by azd\n")); err != nil { + if err := writeTeamsAppFileAtomically(markerPath, []byte(teamsAppPackageMarkerContent(zipBytes))); err != nil { if !packageExisted { if removeErr := os.Remove(packagePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { log.Printf( @@ -323,7 +339,7 @@ func writeTeamsAppFileAtomically(path string, data []byte) error { // deployment. A package azd does not own (no marker) or a missing file is left // untouched. func removeOwnedTeamsAppPackage(packagePath, markerPath string) { - if !teamsAppPackageIsOwned(markerPath) { + if !teamsAppPackageIsOwned(packagePath, markerPath) { return } if err := os.Remove(packagePath); err != nil && !errors.Is(err, os.ErrNotExist) { 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 92005018d94..69f0d0f7741 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 @@ -124,7 +124,7 @@ func TestCommitTeamsAppPackage_WritesWhenAbsent(t *testing.T) { if string(data) != "AZD-GENERATED" { t.Errorf("package content = %q", string(data)) } - if !teamsAppPackageIsOwned(marker) { + if !teamsAppPackageIsOwned(pkg, marker) { t.Errorf("ownership marker must be written") } } @@ -202,11 +202,51 @@ func TestTeamsAppPackageIsOwned_RejectsSymlinkMarker(t *testing.T) { t.Skipf("symlink not supported in this environment: %v", err) } - if teamsAppPackageIsOwned(marker) { + if teamsAppPackageIsOwned(filepath.Join(dir, teamsAppPackageFile), marker) { t.Fatal("symlink marker must not mark a package as azd-owned") } } +func TestTeamsAppPackageIsOwned_RejectsLegacyMarkerWithoutDigest(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("AZD-GENERATED"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte("generated by azd\n"), 0o600); err != nil { + t.Fatal(err) + } + + if teamsAppPackageIsOwned(pkg, marker) { + t.Fatal("legacy marker without digest must not mark a package as azd-owned") + } +} + +func TestCommitTeamsAppPackage_PreservesCustomizedGeneratedPackage(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("AZD-GENERATED-CUSTOMIZED"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte(teamsAppPackageMarkerContent([]byte("AZD-GENERATED"))), 0o600); err != nil { + t.Fatal(err) + } + + got, err := commitTeamsAppPackage(pkg, marker, []byte("NEW")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("expected empty path when leaving a customized package, got %q", got) + } + data, _ := os.ReadFile(pkg) + if string(data) != "AZD-GENERATED-CUSTOMIZED" { + t.Errorf("customized package was clobbered; content = %q", string(data)) + } +} + func TestCommitTeamsAppPackage_OverwritesOwned(t *testing.T) { dir := t.TempDir() pkg := filepath.Join(dir, teamsAppPackageFile) @@ -214,7 +254,7 @@ func TestCommitTeamsAppPackage_OverwritesOwned(t *testing.T) { if err := os.WriteFile(pkg, []byte("OLD"), 0o600); err != nil { t.Fatal(err) } - if err := os.WriteFile(marker, []byte("generated by azd\n"), 0o600); err != nil { + if err := os.WriteFile(marker, []byte(teamsAppPackageMarkerContent([]byte("OLD"))), 0o600); err != nil { t.Fatal(err) } @@ -229,6 +269,9 @@ func TestCommitTeamsAppPackage_OverwritesOwned(t *testing.T) { if string(data) != "NEW" { t.Errorf("owned package should be overwritten; content = %q", string(data)) } + if !teamsAppPackageIsOwned(pkg, marker) { + t.Errorf("marker must be updated to the new package digest") + } } func TestRemoveOwnedTeamsAppPackage_PreservesUnowned(t *testing.T) { @@ -253,7 +296,7 @@ func TestRemoveOwnedTeamsAppPackage_RemovesOwned(t *testing.T) { if err := os.WriteFile(pkg, []byte("AZD-GENERATED"), 0o600); err != nil { t.Fatal(err) } - if err := os.WriteFile(marker, []byte("generated by azd\n"), 0o600); err != nil { + if err := os.WriteFile(marker, []byte(teamsAppPackageMarkerContent([]byte("AZD-GENERATED"))), 0o600); err != nil { t.Fatal(err) } @@ -266,3 +309,24 @@ func TestRemoveOwnedTeamsAppPackage_RemovesOwned(t *testing.T) { t.Errorf("marker must be removed") } } + +func TestRemoveOwnedTeamsAppPackage_PreservesCustomizedGeneratedPackage(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("AZD-GENERATED-CUSTOMIZED"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte(teamsAppPackageMarkerContent([]byte("AZD-GENERATED"))), 0o600); err != nil { + t.Fatal(err) + } + + removeOwnedTeamsAppPackage(pkg, marker) + + if _, err := os.Stat(pkg); err != nil { + t.Errorf("customized package must be preserved: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Errorf("marker for customized package must be preserved: %v", err) + } +} From dcddab6b2f07417ff7ee6684c442509ed3493e96 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:19:18 +0800 Subject: [PATCH 20/28] Keep Teams package marker state consistent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/listen_activity.go | 30 +++++++-- .../internal/cmd/listen_activity_test.go | 66 +++++++++++++++++++ 2 files changed, 89 insertions(+), 7 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 f39a22c8e2a..c684998e3fc 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 @@ -271,8 +271,13 @@ func teamsAppPackageMarkerContent(zipBytes []byte) string { // returned so the manual guide is used instead of clobbering the user's file. func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (string, error) { packageExisted := false + var previousPackageBytes []byte if _, err := os.Stat(packagePath); err == nil { packageExisted = true + previousPackageBytes, err = os.ReadFile(packagePath) + if err != nil { + return "", err + } } else if !errors.Is(err, os.ErrNotExist) { return "", err } @@ -287,8 +292,16 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str if err := writeTeamsAppPackageAtomically(packagePath, zipBytes); err != nil { return "", err } - if err := writeTeamsAppFileAtomically(markerPath, []byte(teamsAppPackageMarkerContent(zipBytes))); err != nil { - if !packageExisted { + if err := writeTeamsAppMarkerAtomically(markerPath, []byte(teamsAppPackageMarkerContent(zipBytes))); err != nil { + if packageExisted { + if restoreErr := writeTeamsAppPackageAtomically(packagePath, previousPackageBytes); restoreErr != nil { + log.Printf( + "postdeploy: could not restore Teams app package %q after marker write failed: %v", + packagePath, + restoreErr, + ) + } + } else { if removeErr := os.Remove(packagePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { log.Printf( "postdeploy: could not roll back Teams app package %q after marker write failed: %v", @@ -302,9 +315,11 @@ func commitTeamsAppPackage(packagePath, markerPath string, zipBytes []byte) (str return packagePath, nil } -func writeTeamsAppPackageAtomically(packagePath string, zipBytes []byte) error { - return writeTeamsAppFileAtomically(packagePath, zipBytes) -} +var ( + writeTeamsAppPackageAtomically = writeTeamsAppFileAtomically + writeTeamsAppMarkerAtomically = writeTeamsAppFileAtomically + removeTeamsAppFile = os.Remove +) func writeTeamsAppFileAtomically(path string, data []byte) error { tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") @@ -342,10 +357,11 @@ func removeOwnedTeamsAppPackage(packagePath, markerPath string) { if !teamsAppPackageIsOwned(packagePath, markerPath) { return } - if err := os.Remove(packagePath); err != nil && !errors.Is(err, os.ErrNotExist) { + if err := removeTeamsAppFile(packagePath); err != nil && !errors.Is(err, os.ErrNotExist) { log.Printf("postdeploy: could not remove stale Teams app package %q: %v", packagePath, err) + return } - if err := os.Remove(markerPath); err != nil && !errors.Is(err, os.ErrNotExist) { + if err := removeTeamsAppFile(markerPath); err != nil && !errors.Is(err, os.ErrNotExist) { log.Printf("postdeploy: could not remove Teams app package marker %q: %v", markerPath, err) } } 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 69f0d0f7741..fc84ad88a8e 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 @@ -4,6 +4,7 @@ package cmd import ( + "errors" "os" "path/filepath" "strings" @@ -146,6 +147,40 @@ func TestCommitTeamsAppPackage_RollsBackNewPackageWhenMarkerWriteFails(t *testin } } +func TestCommitTeamsAppPackage_RestoresOwnedPackageWhenMarkerWriteFails(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("OLD"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte(teamsAppPackageMarkerContent([]byte("OLD"))), 0o600); err != nil { + t.Fatal(err) + } + oldWriteMarker := writeTeamsAppMarkerAtomically + writeTeamsAppMarkerAtomically = func(string, []byte) error { + return errors.New("marker write failed") + } + t.Cleanup(func() { + writeTeamsAppMarkerAtomically = oldWriteMarker + }) + + got, err := commitTeamsAppPackage(pkg, marker, []byte("NEW")) + if err == nil { + t.Fatal("expected marker write error") + } + if got != "" { + t.Errorf("path = %q, want empty path on failure", got) + } + data, _ := os.ReadFile(pkg) + if string(data) != "OLD" { + t.Errorf("owned package must be restored after marker write fails; content = %q", string(data)) + } + if !teamsAppPackageIsOwned(pkg, marker) { + t.Errorf("restored package must still match the ownership marker") + } +} + func TestCommitTeamsAppPackage_DoesNotUseFixedTempPath(t *testing.T) { dir := t.TempDir() pkg := filepath.Join(dir, teamsAppPackageFile) @@ -310,6 +345,37 @@ func TestRemoveOwnedTeamsAppPackage_RemovesOwned(t *testing.T) { } } +func TestRemoveOwnedTeamsAppPackage_KeepsMarkerWhenPackageRemoveFails(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, teamsAppPackageFile) + marker := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(pkg, []byte("AZD-GENERATED"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte(teamsAppPackageMarkerContent([]byte("AZD-GENERATED"))), 0o600); err != nil { + t.Fatal(err) + } + oldRemove := removeTeamsAppFile + removeTeamsAppFile = func(path string) error { + if path == pkg { + return errors.New("package remove failed") + } + return oldRemove(path) + } + t.Cleanup(func() { + removeTeamsAppFile = oldRemove + }) + + removeOwnedTeamsAppPackage(pkg, marker) + + if _, err := os.Stat(pkg); err != nil { + t.Errorf("package must remain when removal fails: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Errorf("marker must remain when package removal fails: %v", err) + } +} + func TestRemoveOwnedTeamsAppPackage_PreservesCustomizedGeneratedPackage(t *testing.T) { dir := t.TempDir() pkg := filepath.Join(dir, teamsAppPackageFile) From 6f5cdf156ceb6461b9da9f1285aa97fdf8f2a118 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:03:55 +0800 Subject: [PATCH 21/28] Apply Teams package ownership to pack output directories Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../azure.ai.agents/internal/cmd/pack.go | 33 ++++++------ .../azure.ai.agents/internal/cmd/pack_test.go | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go index 6f647e99fe2..33f8e1392bb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go @@ -132,21 +132,8 @@ func (a *PackAction) Run(ctx context.Context) error { if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } - if a.flags.outputDir == "" { - markerPath := filepath.Join(filepath.Dir(outputPath), teamsAppPackageMarkerFile) - written, err := commitTeamsAppPackage(outputPath, markerPath, zipBytes) - if err != nil { - return fmt.Errorf("failed to write Teams app package %q: %w", outputPath, err) - } - if written == "" { - return exterrors.Validation( - exterrors.CodeInvalidFilePath, - fmt.Sprintf("Teams app package %q already exists and was not generated by azd", outputPath), - "remove the existing file or use --output-dir to write the generated package elsewhere", - ) - } - } else if err := writeTeamsAppPackageAtomically(outputPath, zipBytes); err != nil { - return fmt.Errorf("failed to write Teams app package %q: %w", outputPath, err) + if err := writeTeamsAppPackOutput(outputPath, zipBytes); err != nil { + return err } fmt.Printf("Teams app package written to %s\n", outputPath) @@ -155,6 +142,22 @@ func (a *PackAction) Run(ctx context.Context) error { return nil } +func writeTeamsAppPackOutput(outputPath string, zipBytes []byte) error { + markerPath := filepath.Join(filepath.Dir(outputPath), teamsAppPackageMarkerFile) + written, err := commitTeamsAppPackage(outputPath, markerPath, zipBytes) + if err != nil { + return fmt.Errorf("failed to write Teams app package %q: %w", outputPath, err) + } + if written == "" { + return exterrors.Validation( + exterrors.CodeInvalidFilePath, + fmt.Sprintf("Teams app package %q already exists and was not generated by azd", outputPath), + "remove the existing file or choose a different --output-dir", + ) + } + return nil +} + // resolveOutputPath returns the absolute path appPackage.zip is written to. When // --output-dir is set it is joined with the package file name; otherwise the // package is written next to the agent source, matching the deploy hook's location. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go new file mode 100644 index 00000000000..1d209864fcc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteTeamsAppPackOutput_PreservesUnownedPackage(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, teamsAppPackageFile) + if err := os.WriteFile(outputPath, []byte("USER-CUSTOMIZED"), 0o600); err != nil { + t.Fatal(err) + } + + err := writeTeamsAppPackOutput(outputPath, []byte("AZD-GENERATED")) + if err == nil { + t.Fatal("expected existing unowned package error") + } + if !strings.Contains(err.Error(), "already exists and was not generated by azd") { + t.Fatalf("unexpected error: %v", err) + } + data, _ := os.ReadFile(outputPath) + if string(data) != "USER-CUSTOMIZED" { + t.Errorf("unowned package was overwritten; content = %q", string(data)) + } +} + +func TestWriteTeamsAppPackOutput_OverwritesOwnedPackage(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, teamsAppPackageFile) + markerPath := filepath.Join(dir, teamsAppPackageMarkerFile) + if err := os.WriteFile(outputPath, []byte("OLD"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(markerPath, []byte(teamsAppPackageMarkerContent([]byte("OLD"))), 0o600); err != nil { + t.Fatal(err) + } + + if err := writeTeamsAppPackOutput(outputPath, []byte("NEW")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, _ := os.ReadFile(outputPath) + if string(data) != "NEW" { + t.Errorf("owned package should be overwritten; content = %q", string(data)) + } + if !teamsAppPackageIsOwned(outputPath, markerPath) { + t.Errorf("marker must be updated for the new package") + } +} From fad7afd490fc4155f5b0cc9b81978a5705bb0c85 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:20:27 +0800 Subject: [PATCH 22/28] Apply adopted agent names only for explicit flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../azure.ai.agents/internal/cmd/init.go | 26 ++++++++++------- .../internal/cmd/init_adopt.go | 29 ++++++++++++++----- .../internal/cmd/init_adopt_test.go | 22 ++++++++++++++ 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 83ecd8c3a5a..4bc89227093 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -55,6 +55,7 @@ type initFlags struct { model string manifestPointer string agentName string + agentNameExplicit bool src string env string protocols []string @@ -1144,6 +1145,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } ctx := azdext.WithAccessToken(cmd.Context()) + flags.agentNameExplicit = cmd.Flags().Changed("agent-name") azdClient, err := azdext.NewAzdClient() if err != nil { @@ -1425,23 +1427,27 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, ) } - // Resolve the agent name BEFORE creating the project - // folder so the folder and agent identity use the same - // name. Use the azure.yaml project name as the default, - // falling back to the template title. + // Resolve --agent-name only when the user explicitly + // provided it. Unified azure.yaml adoption can contain + // multiple agent services, so an interactive/default + // single name must not be treated as an override. defaultName := foundryProjectName(content) if defaultName == "" { defaultName = folderNameStrippingParenSuffix(selectedTemplate.Title) } - resolvedName, err := resolveInitAgentName(ctx, azdClient, flags, defaultName) - if err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("initialization was cancelled") + resolvedName := defaultName + if flags.agentNameExplicit { + var err error + resolvedName, err = resolveInitAgentName(ctx, azdClient, flags, defaultName) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err } - return err + flags.agentName = resolvedName } - flags.agentName = resolvedName if flags.src == "" && resolvedName != "" { flags.src = sanitizeAgentName(resolvedName) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 6cf32240037..2d607e40a3e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -892,15 +892,12 @@ func runInitFromAzureYaml( content []byte, ) error { projectName := foundryProjectName(content) - agentNameOverride := strings.TrimSpace(flags.agentName) + agentNameOverride, err := adoptedAgentNameOverride(flags) + if err != nil { + return err + } if agentNameOverride != "" { - validatedName, err := validateInitAgentName(agentNameOverride) - if err != nil { - return err - } - flags.agentName = validatedName - agentNameOverride = validatedName - projectName = validatedName + projectName = agentNameOverride } targetDir, folderDisplay := adoptTargetDir(flags, projectName) @@ -1078,6 +1075,22 @@ func runInitFromAzureYaml( return nil } +func adoptedAgentNameOverride(flags *initFlags) (string, error) { + if !flags.agentNameExplicit { + return "", nil + } + agentNameOverride := strings.TrimSpace(flags.agentName) + if agentNameOverride == "" { + return "", nil + } + validatedName, err := validateInitAgentName(agentNameOverride) + if err != nil { + return "", err + } + flags.agentName = validatedName + return validatedName, nil +} + // adoptTargetDir resolves the directory the adopted project is created in and // the display path for the "created folder" next-step hint. An explicit --src // (or positional directory) wins; otherwise a new folder named after the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index 9c32dbe50c1..3732d84bdf3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -887,6 +887,28 @@ func TestApplyAdoptedAgentNameOverride_RejectsNoNamedAgent(t *testing.T) { require.Empty(t, server.configValues) } +func TestAdoptedAgentNameOverride_IgnoresResolvedDefaultWhenFlagNotExplicit(t *testing.T) { + t.Parallel() + + flags := &initFlags{agentName: "resolved-template-default"} + + got, err := adoptedAgentNameOverride(flags) + require.NoError(t, err) + require.Empty(t, got) + require.Equal(t, "resolved-template-default", flags.agentName) +} + +func TestAdoptedAgentNameOverride_UsesExplicitFlag(t *testing.T) { + t.Parallel() + + flags := &initFlags{agentName: "test0804", agentNameExplicit: true} + + got, err := adoptedAgentNameOverride(flags) + require.NoError(t, err) + require.Equal(t, "test0804", got) + require.Equal(t, "test0804", flags.agentName) +} + // TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint // writes the endpoint to the existing azure.ai.project service via // SetServiceConfigValue when a valid project is provided. From 68566b8da8a4e0c59189fb029cbfc30c1b673957 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:24:00 +0800 Subject: [PATCH 23/28] Validate Teams publish and adopted agent names earlier Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/init_adopt.go | 63 ++++++++++++++++++ .../internal/cmd/init_adopt_test.go | 66 +++++++++++++++++++ .../pkg/agents/agent_api/microsoft365.go | 3 + .../pkg/agents/agent_api/microsoft365_test.go | 10 +++ 4 files changed, 142 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 2d607e40a3e..4ee16963ab2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -916,6 +916,11 @@ func runInitFromAzureYaml( "'azd ai agent init -m '", ) } + if agentNameOverride != "" { + if err := validateAdoptedAgentNameOverride(content); err != nil { + return err + } + } // Stage the sample as a local template directory (azure.yaml at its root // alongside referenced files) that azd-core can adopt with `azd init -t`. @@ -1075,6 +1080,64 @@ func runInitFromAzureYaml( return nil } +type adoptedAgentNameOverrideAzureYaml struct { + Services map[string]adoptedAgentNameOverrideService `yaml:"services"` +} + +type adoptedAgentNameOverrideService struct { + Host string `yaml:"host"` + Kind string `yaml:"kind"` + Name string `yaml:"name"` + Config struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` + } `yaml:"config"` +} + +func validateAdoptedAgentNameOverride(content []byte) error { + var doc adoptedAgentNameOverrideAzureYaml + if err := yaml.Unmarshal(content, &doc); err != nil { + return fmt.Errorf("parsing adopted azure.yaml for agent name override: %w", err) + } + + namedAgents := 0 + for _, svc := range doc.Services { + if svc.Host != AiAgentHost { + continue + } + if adoptedAgentNameOverrideServiceName(svc) == "" { + continue + } + namedAgents++ + if namedAgents > 1 { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name cannot be applied to an adopted azure.yaml with multiple agent services", + "update each agent service's `name` in azure.yaml after init, or use a sample with a single agent service", + ) + } + } + if namedAgents == 0 { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name could not be applied because the adopted azure.yaml has no named agent service", + "update the agent service's `name` in azure.yaml after init, or use a sample with a named agent service", + ) + } + + return nil +} + +func adoptedAgentNameOverrideServiceName(svc adoptedAgentNameOverrideService) string { + if strings.TrimSpace(svc.Kind) != "" { + return strings.TrimSpace(svc.Name) + } + if strings.TrimSpace(svc.Config.Kind) != "" { + return strings.TrimSpace(svc.Config.Name) + } + return "" +} + func adoptedAgentNameOverride(flags *initFlags) (string, error) { if !flags.agentNameExplicit { return "", nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index 3732d84bdf3..19c57637215 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -909,6 +909,72 @@ func TestAdoptedAgentNameOverride_UsesExplicitFlag(t *testing.T) { require.Equal(t, "test0804", flags.agentName) } +func TestValidateAdoptedAgentNameOverride_AllowsSingleNamedAgent(t *testing.T) { + t.Parallel() + + content := []byte(`name: sample +services: + agent: + host: azure.ai.agent + kind: hosted + name: echo-activity +`) + + require.NoError(t, validateAdoptedAgentNameOverride(content)) +} + +func TestValidateAdoptedAgentNameOverride_AllowsSingleLegacyNamedAgent(t *testing.T) { + t.Parallel() + + content := []byte(`name: sample +services: + agent: + host: azure.ai.agent + config: + kind: hosted + name: echo-activity +`) + + require.NoError(t, validateAdoptedAgentNameOverride(content)) +} + +func TestValidateAdoptedAgentNameOverride_RejectsMultipleNamedAgents(t *testing.T) { + t.Parallel() + + content := []byte(`name: sample +services: + agent-a: + host: azure.ai.agent + kind: hosted + name: agent-a + agent-b: + host: azure.ai.agent + kind: hosted + name: agent-b +`) + + err := validateAdoptedAgentNameOverride(content) + require.Error(t, err) + require.Contains(t, err.Error(), "multiple agent services") +} + +func TestValidateAdoptedAgentNameOverride_RejectsNoNamedAgent(t *testing.T) { + t.Parallel() + + content := []byte(`name: sample +services: + agent: + host: azure.ai.agent + kind: hosted + project: + host: azure.ai.project +`) + + err := validateAdoptedAgentNameOverride(content) + require.Error(t, err) + require.Contains(t, err.Error(), "no named agent service") +} + // TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint // writes the endpoint to the existing azure.ai.project service via // SetServiceConfigValue when a valid project is provided. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go index ae519cad64d..c52ca993ea4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365.go @@ -167,5 +167,8 @@ func (c *AgentClient) PublishTeamsApp( if result.TitleID == "" { return nil, fmt.Errorf("Teams app publish response was missing titleId") } + if result.TeamsAppID == "" { + return nil, fmt.Errorf("Teams app publish response was missing teamsAppId") + } return &result, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go index 60b4445eb6e..a60e873bf3f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/microsoft365_test.go @@ -135,3 +135,13 @@ func TestPublishTeamsApp_MissingTitleID(t *testing.T) { }) } } + +func TestPublishTeamsApp_MissingTeamsAppID(t *testing.T) { + client, _ := newCaptureClient(http.StatusOK, `{"titleId":"T_123"}`) + + _, err := client.PublishTeamsApp( + t.Context(), "my-agent", TeamsAppPackageRequest{}, Microsoft365APIVersion, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "teamsAppId") +} From a97cf2b68e5a5e0ec65707f598bddf657e4eb29a Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:41:00 +0800 Subject: [PATCH 24/28] Avoid shared Teams app artifact destinations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/listen_activity.go | 50 ++++++++++++++ .../internal/cmd/listen_activity_test.go | 69 +++++++++++++++++++ .../azure.ai.agents/internal/cmd/pack.go | 11 +++ .../azure.ai.agents/internal/cmd/pack_test.go | 48 +++++++++++++ 4 files changed, 178 insertions(+) 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 c684998e3fc..ad56936de05 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 @@ -13,6 +13,7 @@ import ( "log" "os" "path/filepath" + "strings" "text/template" "azureaiagent/internal/pkg/agents/agent_api" @@ -191,6 +192,17 @@ func writeTeamsAppPackage( svc *azdext.ServiceConfig, agentName, subscriptionID, resourceGroup, botName string, ) string { + if hasSharedTeamsArtifactDestination(proj, svc) { + log.Printf( + "postdeploy: skipping Teams app package for service %q because multiple agent services share %q; "+ + "run 'azd ai agent pack %s --output-dir ' to choose a distinct package location", + svc.GetName(), + svc.GetRelativePath(), + svc.GetName(), + ) + return "" + } + packagePath, err := paths.JoinAllowRoot(proj.GetPath(), svc.GetRelativePath(), teamsAppPackageFile) if err != nil { log.Printf("postdeploy: skipping Teams app package: %v", err) @@ -232,6 +244,35 @@ func writeTeamsAppPackage( return written } +func hasSharedTeamsArtifactDestination(proj *azdext.ProjectConfig, svc *azdext.ServiceConfig) bool { + if proj == nil || svc == nil { + return false + } + targetPath := normalizedTeamsArtifactRelativePath(svc.GetRelativePath()) + sharedCount := 0 + for _, other := range proj.GetServices() { + if other.GetHost() != AiAgentHost { + continue + } + if normalizedTeamsArtifactRelativePath(other.GetRelativePath()) != targetPath { + continue + } + sharedCount++ + if sharedCount > 1 { + return true + } + } + return false +} + +func normalizedTeamsArtifactRelativePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + path = "." + } + return filepath.ToSlash(filepath.Clean(path)) +} + // teamsAppPackageIsOwned reports whether appPackage.zip is still the same file // azd generated, as recorded by the sidecar digest marker. azd only removes or // overwrites packages it owns and that users have not customized. @@ -379,6 +420,15 @@ const teamsSetupGuideFile = "TEAMS_APP_SETUP.md" func writeTeamsSetupGuide( proj *azdext.ProjectConfig, svc *azdext.ServiceConfig, agentName, botName, msaAppID, packagePath string, ) string { + if hasSharedTeamsArtifactDestination(proj, svc) { + log.Printf( + "postdeploy: skipping Teams setup guide for service %q because multiple agent services share %q", + svc.GetName(), + svc.GetRelativePath(), + ) + return "" + } + guidePath, err := paths.JoinAllowRoot(proj.GetPath(), svc.GetRelativePath(), teamsSetupGuideFile) if err != nil { log.Printf("postdeploy: skipping Teams setup guide: %v", err) 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 fc84ad88a8e..b30cfe9d0d9 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 @@ -85,6 +85,75 @@ func TestWriteTeamsSetupGuide(t *testing.T) { } } +func TestWriteTeamsSetupGuide_SkipsSharedAgentSourceDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "src"), 0o750); err != nil { + t.Fatal(err) + } + svc := &azdext.ServiceConfig{Name: "agent-a", Host: AiAgentHost, RelativePath: "src"} + proj := &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "agent-a": svc, + "agent-b": {Name: "agent-b", Host: AiAgentHost, RelativePath: "src"}, + }, + } + + path := writeTeamsSetupGuide(proj, svc, "agent-a", "bot-a", "app-id", "") + + if path != "" { + t.Errorf("guide path = %q, want empty for shared source directory", path) + } + if _, err := os.Stat(filepath.Join(root, "src", teamsSetupGuideFile)); !os.IsNotExist(err) { + t.Errorf("setup guide must not be written for a shared source directory") + } +} + +func TestWriteTeamsAppPackage_SkipsSharedAgentSourceDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "src"), 0o750); err != nil { + t.Fatal(err) + } + svc := &azdext.ServiceConfig{Name: "agent-a", Host: AiAgentHost, RelativePath: "src"} + proj := &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "agent-a": svc, + "agent-b": {Name: "agent-b", Host: AiAgentHost, RelativePath: "src"}, + }, + } + + path := writeTeamsAppPackage(t.Context(), nil, proj, svc, "agent-a", "sub", "rg", "bot-a") + + if path != "" { + t.Errorf("package path = %q, want empty for shared source directory", path) + } + if _, err := os.Stat(filepath.Join(root, "src", teamsAppPackageFile)); !os.IsNotExist(err) { + t.Errorf("Teams app package must not be written for a shared source directory") + } +} + +func TestHasSharedTeamsArtifactDestination(t *testing.T) { + sharedSvc := &azdext.ServiceConfig{Name: "agent-a", Host: AiAgentHost, RelativePath: "."} + proj := &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "agent-a": sharedSvc, + "agent-b": {Name: "agent-b", Host: AiAgentHost, RelativePath: ""}, + "web": {Name: "web", Host: "containerapp", RelativePath: "."}, + }, + } + + if !hasSharedTeamsArtifactDestination(proj, sharedSvc) { + t.Fatal("expected shared artifact destination for agent services with the same source directory") + } + + uniqueSvc := &azdext.ServiceConfig{Name: "agent-c", Host: AiAgentHost, RelativePath: "src"} + proj.Services["agent-c"] = uniqueSvc + if hasSharedTeamsArtifactDestination(proj, uniqueSvc) { + t.Fatal("unique agent source directory must not be treated as shared") + } +} + func TestCommitTeamsAppPackage_PreservesUnownedFile(t *testing.T) { dir := t.TempDir() pkg := filepath.Join(dir, teamsAppPackageFile) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go index 33f8e1392bb..7fc1e22edf9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack.go @@ -169,6 +169,17 @@ func (a *PackAction) resolveOutputPath(packCtx *teamsPackContext) (string, error } return abs, nil } + if hasSharedTeamsArtifactDestination(packCtx.proj, packCtx.svc) { + return "", exterrors.Validation( + exterrors.CodeConflictingArguments, + fmt.Sprintf( + "agent service %q shares its source directory %q with another agent service", + packCtx.svc.GetName(), + packCtx.svc.GetRelativePath(), + ), + "pass --output-dir to choose a distinct Teams app package location", + ) + } outputPath, err := paths.JoinAllowRoot( packCtx.proj.GetPath(), packCtx.svc.GetRelativePath(), teamsAppPackageFile, ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go index 1d209864fcc..717721dfc79 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pack_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) func TestWriteTeamsAppPackOutput_PreservesUnownedPackage(t *testing.T) { @@ -52,3 +54,49 @@ func TestWriteTeamsAppPackOutput_OverwritesOwnedPackage(t *testing.T) { t.Errorf("marker must be updated for the new package") } } + +func TestPackResolveOutputPath_RejectsSharedDefaultDestination(t *testing.T) { + root := t.TempDir() + svc := &azdext.ServiceConfig{Name: "agent-a", Host: AiAgentHost, RelativePath: "."} + proj := &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "agent-a": svc, + "agent-b": {Name: "agent-b", Host: AiAgentHost, RelativePath: "."}, + }, + } + action := &PackAction{flags: &packFlags{}} + + _, err := action.resolveOutputPath(&teamsPackContext{proj: proj, svc: svc}) + + if err == nil { + t.Fatal("expected shared default destination error") + } + if !strings.Contains(err.Error(), "shares its source directory") { + t.Fatalf("expected shared source directory error, got %v", err) + } +} + +func TestPackResolveOutputPath_AllowsExplicitOutputDirForSharedSourceDirectory(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + svc := &azdext.ServiceConfig{Name: "agent-a", Host: AiAgentHost, RelativePath: "."} + proj := &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "agent-a": svc, + "agent-b": {Name: "agent-b", Host: AiAgentHost, RelativePath: "."}, + }, + } + action := &PackAction{flags: &packFlags{outputDir: outputDir}} + + got, err := action.resolveOutputPath(&teamsPackContext{proj: proj, svc: svc}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(outputDir, teamsAppPackageFile) + if got != want { + t.Errorf("output path = %q, want %q", got, want) + } +} From 34fb3bbf4c08a5ed200e4089ccf52b8af9127084 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:34:30 +0800 Subject: [PATCH 25/28] Address Teams publish review coverage Add coverage for the publish JSON output contract and handle adopted agent-name overrides for referenced agent services. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/init_adopt.go | 45 +++++------ .../internal/cmd/init_adopt_test.go | 75 ++++++++++++++++--- .../azure.ai.agents/internal/cmd/publish.go | 32 +++++--- .../internal/cmd/publish_test.go | 49 ++++++++++++ 4 files changed, 154 insertions(+), 47 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/publish_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 4ee16963ab2..93d41f75f00 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -756,10 +756,6 @@ func applyAdoptedAgentNameOverride( if svc.GetHost() != AiAgentHost { continue } - currentName, path := adoptedAgentNameConfig(svc) - if currentName == "" { - continue - } if serviceName != "" { return exterrors.Validation( exterrors.CodeConflictingArguments, @@ -768,13 +764,13 @@ func applyAdoptedAgentNameOverride( ) } serviceName = name - configPath = path + configPath = adoptedAgentNameOverrideConfigPath(svc) } if serviceName == "" { return exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-name could not be applied because the adopted azure.yaml has no named agent service", - "update the agent service's `name` in azure.yaml after init, or use a sample with a named agent service", + "--agent-name could not be applied because the adopted azure.yaml has no agent service", + "update the agent service's `name` in azure.yaml after init, or use a sample with an agent service", ) } @@ -793,6 +789,16 @@ func applyAdoptedAgentNameOverride( return nil } +func adoptedAgentNameOverrideConfigPath(svc *azdext.ServiceConfig) string { + if svc == nil { + return "name" + } + if legacy := svc.GetConfig(); legacy != nil && legacy.GetFields()["kind"].GetStringValue() != "" { + return "config.name" + } + return "name" +} + // adoptedAgentNameConfig returns the Foundry agent name and its service-relative // config path for the unified inline shape or deprecated config-nested shape. func adoptedAgentNameConfig(svc *azdext.ServiceConfig) (string, string) { @@ -1100,16 +1106,13 @@ func validateAdoptedAgentNameOverride(content []byte) error { return fmt.Errorf("parsing adopted azure.yaml for agent name override: %w", err) } - namedAgents := 0 + agentServices := 0 for _, svc := range doc.Services { if svc.Host != AiAgentHost { continue } - if adoptedAgentNameOverrideServiceName(svc) == "" { - continue - } - namedAgents++ - if namedAgents > 1 { + agentServices++ + if agentServices > 1 { return exterrors.Validation( exterrors.CodeConflictingArguments, "--agent-name cannot be applied to an adopted azure.yaml with multiple agent services", @@ -1117,27 +1120,17 @@ func validateAdoptedAgentNameOverride(content []byte) error { ) } } - if namedAgents == 0 { + if agentServices == 0 { return exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-name could not be applied because the adopted azure.yaml has no named agent service", - "update the agent service's `name` in azure.yaml after init, or use a sample with a named agent service", + "--agent-name could not be applied because the adopted azure.yaml has no agent service", + "update the agent service's `name` in azure.yaml after init, or use a sample with an agent service", ) } return nil } -func adoptedAgentNameOverrideServiceName(svc adoptedAgentNameOverrideService) string { - if strings.TrimSpace(svc.Kind) != "" { - return strings.TrimSpace(svc.Name) - } - if strings.TrimSpace(svc.Config.Kind) != "" { - return strings.TrimSpace(svc.Config.Name) - } - return "" -} - func adoptedAgentNameOverride(flags *initFlags) (string, error) { if !flags.agentNameExplicit { return "", nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index 19c57637215..893da7bc439 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -828,6 +828,31 @@ func TestApplyAdoptedAgentNameOverride_PersistsFlagName(t *testing.T) { require.Equal(t, "test0804", server.configValues["name"].value) } +func TestApplyAdoptedAgentNameOverride_PersistsFlagNameForRefService(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "agent-service": { + Name: "agent-service", + Host: AiAgentHost, + AdditionalProperties: &structpb.Struct{Fields: map[string]*structpb.Value{ + "$ref": structpb.NewStringValue("./agent.yaml"), + }}, + }, + }, + } + client := newProjectRecorderClient(t, server) + + err := applyAdoptedAgentNameOverride(t.Context(), client, "test0804") + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + require.Equal(t, "agent-service", server.configValues["name"].serviceName) + require.Equal(t, "test0804", server.configValues["name"].value) +} + func TestApplyAdoptedAgentNameOverride_RejectsMultipleAgents(t *testing.T) { t.Parallel() @@ -862,17 +887,14 @@ func TestApplyAdoptedAgentNameOverride_RejectsMultipleAgents(t *testing.T) { require.Empty(t, server.configValues) } -func TestApplyAdoptedAgentNameOverride_RejectsNoNamedAgent(t *testing.T) { +func TestApplyAdoptedAgentNameOverride_RejectsNoAgentService(t *testing.T) { t.Parallel() server := &recordingProjectServer{ existing: map[string]*azdext.ServiceConfig{ - "agent-service": { - Name: "agent-service", - Host: AiAgentHost, - AdditionalProperties: &structpb.Struct{Fields: map[string]*structpb.Value{ - "kind": structpb.NewStringValue("hosted"), - }}, + "ai-project": { + Name: "ai-project", + Host: AiProjectHost, }, }, } @@ -880,7 +902,7 @@ func TestApplyAdoptedAgentNameOverride_RejectsNoNamedAgent(t *testing.T) { err := applyAdoptedAgentNameOverride(t.Context(), client, "test0804") require.Error(t, err) - require.Contains(t, err.Error(), "no named agent service") + require.Contains(t, err.Error(), "no agent service") server.mu.Lock() defer server.mu.Unlock() @@ -923,6 +945,19 @@ services: require.NoError(t, validateAdoptedAgentNameOverride(content)) } +func TestValidateAdoptedAgentNameOverride_AllowsSingleRefAgent(t *testing.T) { + t.Parallel() + + content := []byte(`name: sample +services: + agent: + host: azure.ai.agent + $ref: ./agent.yaml +`) + + require.NoError(t, validateAdoptedAgentNameOverride(content)) +} + func TestValidateAdoptedAgentNameOverride_AllowsSingleLegacyNamedAgent(t *testing.T) { t.Parallel() @@ -938,7 +973,7 @@ services: require.NoError(t, validateAdoptedAgentNameOverride(content)) } -func TestValidateAdoptedAgentNameOverride_RejectsMultipleNamedAgents(t *testing.T) { +func TestValidateAdoptedAgentNameOverride_RejectsMultipleAgents(t *testing.T) { t.Parallel() content := []byte(`name: sample @@ -958,21 +993,37 @@ services: require.Contains(t, err.Error(), "multiple agent services") } -func TestValidateAdoptedAgentNameOverride_RejectsNoNamedAgent(t *testing.T) { +func TestValidateAdoptedAgentNameOverride_RejectsInlineAndRefAgents(t *testing.T) { t.Parallel() content := []byte(`name: sample services: - agent: + agent-a: host: azure.ai.agent kind: hosted + name: agent-a + agent-b: + host: azure.ai.agent + $ref: ./agent.yaml +`) + + err := validateAdoptedAgentNameOverride(content) + require.Error(t, err) + require.Contains(t, err.Error(), "multiple agent services") +} + +func TestValidateAdoptedAgentNameOverride_RejectsNoAgentService(t *testing.T) { + t.Parallel() + + content := []byte(`name: sample +services: project: host: azure.ai.project `) err := validateAdoptedAgentNameOverride(content) require.Error(t, err) - require.Contains(t, err.Error(), "no named agent service") + require.Contains(t, err.Error(), "no agent service") } // TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go index adfa16c9bd7..281ed268556 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish.go @@ -7,6 +7,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "os" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" @@ -149,7 +151,19 @@ func (a *PublishAction) Run(ctx context.Context) error { deepLink := teamsAppDeepLink(result.TitleID) - if a.flags.output == "json" { + return writePublishResult(os.Stdout, a.flags.output, result, scope, displayName, packCtx.agentName, deepLink) +} + +func writePublishResult( + w io.Writer, + output string, + result *agent_api.TeamsAppPublishResult, + scope teamsPackScope, + displayName string, + agentName string, + deepLink string, +) error { + if output == "json" { payload := map[string]string{ "titleId": result.TitleID, "teamsAppId": result.TeamsAppID, @@ -161,19 +175,19 @@ func (a *PublishAction) Run(ctx context.Context) error { if jsonErr != nil { return fmt.Errorf("failed to marshal response: %w", jsonErr) } - fmt.Println(string(data)) - return nil + _, err := fmt.Fprintln(w, string(data)) + return err } - fmt.Printf("Published Teams app %q for agent %q (scope: %s)\n", displayName, packCtx.agentName, scope.flag) - fmt.Printf(" Title ID: %s\n", result.TitleID) - fmt.Printf(" Teams App ID: %s\n", result.TeamsAppID) - fmt.Printf(" Install link: %s\n", deepLink) + fmt.Fprintf(w, "Published Teams app %q for agent %q (scope: %s)\n", displayName, agentName, scope.flag) + fmt.Fprintf(w, " Title ID: %s\n", result.TitleID) + fmt.Fprintf(w, " Teams App ID: %s\n", result.TeamsAppID) + fmt.Fprintf(w, " Install link: %s\n", deepLink) switch scope.flag { case "tenant": - fmt.Println("The app is submitted to the organization catalog and awaits IT-admin approval.") + fmt.Fprintln(w, "The app is submitted to the organization catalog and awaits IT-admin approval.") default: - fmt.Println("Share the install link above; recipients can add the app without tenant-admin approval.") + fmt.Fprintln(w, "Share the install link above; recipients can add the app without tenant-admin approval.") } return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/publish_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish_test.go new file mode 100644 index 00000000000..6162d26f6fe --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/publish_test.go @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/require" +) + +func TestWritePublishResultJson(t *testing.T) { + t.Parallel() + + scope, err := resolveTeamsPackScope("shared") + require.NoError(t, err) + + var stdout bytes.Buffer + err = writePublishResult( + &stdout, + "json", + &agent_api.TeamsAppPublishResult{ + TitleID: "T_title", + TeamsAppID: "app-id", + }, + scope, + "Contoso Helper", + "agent-service", + "https://teams.microsoft.com/v2/#/l/app/?titleId=T_title", + ) + require.NoError(t, err) + + var payload map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &payload)) + require.Equal(t, map[string]string{ + "titleId": "T_title", + "teamsAppId": "app-id", + "scope": "shared", + "displayName": "Contoso Helper", + "deepLink": "https://teams.microsoft.com/v2/#/l/app/?titleId=T_title", + }, payload) + require.NotContains(t, stdout.String(), "Publishing Teams app") + require.False(t, strings.Contains(stdout.String(), "Published Teams app")) +} From 809186998923d4b76bde53f06a2b3b61d74a0286 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:20:15 +0800 Subject: [PATCH 26/28] Resolve adopted agent ref validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/init_adopt.go | 57 +++++++++++-------- .../internal/cmd/init_adopt_test.go | 48 ++++++++++++++-- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index f65b65539dd..7b55e7143e8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -983,12 +983,6 @@ func runInitFromAzureYaml( "'azd ai agent init -m '", ) } - if agentNameOverride != "" { - if err := validateAdoptedAgentNameOverride(content); err != nil { - return err - } - } - // Stage the sample as a local template directory (azure.yaml at its root // alongside referenced files) that azd-core can adopt with `azd init -t`. stagingDir, cleanup, err := stageAzureYamlTemplate(ctx, flags, azdClient, httpClient) @@ -1000,6 +994,17 @@ func runInitFromAzureYaml( if err := validateStagedAzureYaml(stagingDir, flags.manifestPointer); err != nil { return err } + if agentNameOverride != "" { + // Validate against the fully staged template so services whose host lives + // inside a local $ref are counted the same way azd-core will load them. + stagedContent, err := os.ReadFile(filepath.Join(stagingDir, "azure.yaml")) + if err != nil { + return fmt.Errorf("reading staged azure.yaml for agent name override: %w", err) + } + if err := validateAdoptedAgentNameOverride(stagedContent, stagingDir); err != nil { + return err + } + } fmt.Println(output.WithGrayFormat("Adopting the sample's azure.yaml as your project manifest...")) @@ -1151,29 +1156,33 @@ func runInitFromAzureYaml( return nil } -type adoptedAgentNameOverrideAzureYaml struct { - Services map[string]adoptedAgentNameOverrideService `yaml:"services"` -} - -type adoptedAgentNameOverrideService struct { - Host string `yaml:"host"` - Kind string `yaml:"kind"` - Name string `yaml:"name"` - Config struct { - Kind string `yaml:"kind"` - Name string `yaml:"name"` - } `yaml:"config"` -} - -func validateAdoptedAgentNameOverride(content []byte) error { - var doc adoptedAgentNameOverrideAzureYaml +func validateAdoptedAgentNameOverride(content []byte, projectRoot string) error { + var doc map[string]any if err := yaml.Unmarshal(content, &doc); err != nil { return fmt.Errorf("parsing adopted azure.yaml for agent name override: %w", err) } + services, ok := doc["services"].(map[string]any) + if !ok { + services = map[string]any{} + } + agentServices := 0 - for _, svc := range doc.Services { - if svc.Host != AiAgentHost { + for serviceName, svc := range services { + svcMap, ok := svc.(map[string]any) + if !ok { + continue + } + if hasAzureYamlFileRef(svcMap) && projectRoot != "" { + resolved, err := foundry.ResolveFileRefs(svcMap, projectRoot) + if err != nil { + return fmt.Errorf("resolving $ref includes for service %q: %w", serviceName, err) + } + svcMap = resolved + } + + host, _ := svcMap["host"].(string) + if host != AiAgentHost { continue } agentServices++ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index 69420b07f44..ae6afc933d9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -1078,7 +1078,7 @@ services: name: echo-activity `) - require.NoError(t, validateAdoptedAgentNameOverride(content)) + require.NoError(t, validateAdoptedAgentNameOverride(content, "")) } func TestValidateAdoptedAgentNameOverride_AllowsSingleRefAgent(t *testing.T) { @@ -1091,7 +1091,22 @@ services: $ref: ./agent.yaml `) - require.NoError(t, validateAdoptedAgentNameOverride(content)) + require.NoError(t, validateAdoptedAgentNameOverride(content, "")) +} + +func TestValidateAdoptedAgentNameOverride_AllowsSingleRefOnlyAgent(t *testing.T) { + t.Parallel() + + root := t.TempDir() + refPath := filepath.Join(root, "agent.yaml") + require.NoError(t, os.WriteFile(refPath, []byte("host: azure.ai.agent\nkind: hosted\n"), 0o600)) + content := []byte(`name: sample +services: + agent: + $ref: ./agent.yaml +`) + + require.NoError(t, validateAdoptedAgentNameOverride(content, root)) } func TestValidateAdoptedAgentNameOverride_AllowsSingleLegacyNamedAgent(t *testing.T) { @@ -1106,7 +1121,7 @@ services: name: echo-activity `) - require.NoError(t, validateAdoptedAgentNameOverride(content)) + require.NoError(t, validateAdoptedAgentNameOverride(content, "")) } func TestValidateAdoptedAgentNameOverride_RejectsMultipleAgents(t *testing.T) { @@ -1124,7 +1139,7 @@ services: name: agent-b `) - err := validateAdoptedAgentNameOverride(content) + err := validateAdoptedAgentNameOverride(content, "") require.Error(t, err) require.Contains(t, err.Error(), "multiple agent services") } @@ -1143,7 +1158,28 @@ services: $ref: ./agent.yaml `) - err := validateAdoptedAgentNameOverride(content) + err := validateAdoptedAgentNameOverride(content, "") + require.Error(t, err) + require.Contains(t, err.Error(), "multiple agent services") +} + +func TestValidateAdoptedAgentNameOverride_RejectsInlineAndRefOnlyAgents(t *testing.T) { + t.Parallel() + + root := t.TempDir() + refPath := filepath.Join(root, "agent-b.yaml") + require.NoError(t, os.WriteFile(refPath, []byte("host: azure.ai.agent\nkind: hosted\n"), 0o600)) + content := []byte(`name: sample +services: + agent-a: + host: azure.ai.agent + kind: hosted + name: agent-a + agent-b: + $ref: ./agent-b.yaml +`) + + err := validateAdoptedAgentNameOverride(content, root) require.Error(t, err) require.Contains(t, err.Error(), "multiple agent services") } @@ -1157,7 +1193,7 @@ services: host: azure.ai.project `) - err := validateAdoptedAgentNameOverride(content) + err := validateAdoptedAgentNameOverride(content, "") require.Error(t, err) require.Contains(t, err.Error(), "no agent service") } From a9204f2cf3fe2a30b662b3ed2a771a91fef021dc Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:36:05 +0800 Subject: [PATCH 27/28] Use deployed bot target for Teams packaging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- .../internal/cmd/teams_pack.go | 34 ++++++++++++++++- .../internal/cmd/teams_pack_test.go | 38 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go index d641903f5eb..f77b26e9f74 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack.go @@ -13,6 +13,7 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/botservice" + "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -163,9 +164,21 @@ func resolveTeamsPackContext( if err != nil { return nil, err } + botName, err := readEnvValue(ctx, azdClient, envName, envkey.AgentBotName(svc.Name)) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeAgentNotDeployed, + fmt.Sprintf("agent %q has no recorded Teams bot name in environment %q", agentName, envName), + "run 'azd deploy' first; the Teams bot name is recorded during deploy and is required "+ + "before packaging or publishing", + ) + } + botResourceGroup := readOptionalEnvValue(ctx, azdClient, envName, envkey.AgentBotResourceGroup(svc.Name)) - botName := botservice.BotName(agentName, botservice.BotScopeSalt(subscriptionID, resourceGroup)) - botArmID := botservice.BotArmID(subscriptionID, resourceGroup, botName) + botArmID, err := teamsBotArmID(subscriptionID, resourceGroup, botName, botResourceGroup) + if err != nil { + return nil, err + } endpoint, err := resolveAgentEndpoint(ctx, "", "") if err != nil { @@ -185,6 +198,23 @@ func resolveTeamsPackContext( }, nil } +func teamsBotArmID(subscriptionID, defaultResourceGroup, botName, botResourceGroup string) (string, error) { + botName = strings.TrimSpace(botName) + if botName == "" { + return "", exterrors.Dependency( + exterrors.CodeAgentNotDeployed, + "the deployed Teams bot name is not recorded", + "run 'azd deploy' first; the Teams bot name is recorded during deploy and is required "+ + "before packaging or publishing", + ) + } + resourceGroup := strings.TrimSpace(botResourceGroup) + if resourceGroup == "" { + resourceGroup = strings.TrimSpace(defaultResourceGroup) + } + return botservice.BotArmID(subscriptionID, resourceGroup, botName), nil +} + // teamsAppRequestOptions carries the user-overridable display metadata for a Teams // app package/publish request. Zero-value fields fall back to sensible defaults // derived from the agent name. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go index c73db732390..38b6c38664a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_pack_test.go @@ -76,6 +76,44 @@ func TestBuildTeamsAppPackageRequest(t *testing.T) { } } +func TestTeamsBotArmIDUsesPersistedBotTarget(t *testing.T) { + got, err := teamsBotArmID("sub-1", "default-rg", "adopted-bot", "published-rg") + if err != nil { + t.Fatal(err) + } + + want := "/subscriptions/sub-1/resourceGroups/published-rg/providers/Microsoft.BotService/botServices/adopted-bot" + if got != want { + t.Errorf("teamsBotArmID = %q, want %q", got, want) + } +} + +func TestTeamsBotArmIDFallsBackToEnvironmentResourceGroup(t *testing.T) { + got, err := teamsBotArmID("sub-1", "default-rg", "owned-bot", "") + if err != nil { + t.Fatal(err) + } + + want := "/subscriptions/sub-1/resourceGroups/default-rg/providers/Microsoft.BotService/botServices/owned-bot" + if got != want { + t.Errorf("teamsBotArmID = %q, want %q", got, want) + } +} + +func TestTeamsBotArmIDRequiresPersistedBotName(t *testing.T) { + _, err := teamsBotArmID("sub-1", "default-rg", " ", "published-rg") + if err == nil { + t.Fatal("expected missing bot name error") + } + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected *azdext.LocalError, got %T", err) + } + if localErr.Code != exterrors.CodeAgentNotDeployed { + t.Errorf("error code = %q, want %q", localErr.Code, exterrors.CodeAgentNotDeployed) + } +} + func TestTeamsAppDeepLink(t *testing.T) { got := teamsAppDeepLink("T_abc 123") want := "https://teams.microsoft.com/v2/#/l/app/?source=agent-details-page&titleId=T_abc+123&launchAgent=join_launcher_web" From 0bc104e967a5153dbd056b72db916e3022cf2962 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:47:41 +0800 Subject: [PATCH 28/28] Use persisted bot resource group in postdeploy packaging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b480c5f-6c14-4d28-a19a-634f28621671 --- cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go | 6 ++++++ 1 file changed, 6 insertions(+) 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 426ea247225..36b982cb303 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -416,6 +416,12 @@ func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *a subscriptionID, subErr := readEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID") resourceGroup, rgErr := readEnvValue(ctx, azdClient, envName, "AZURE_RESOURCE_GROUP") if subErr == nil && rgErr == nil { + botResourceGroup := readOptionalEnvValue( + ctx, azdClient, envName, envkey.AgentBotResourceGroup(svc.Name), + ) + if botResourceGroup != "" { + resourceGroup = botResourceGroup + } agentClient := agent_api.NewAgentClient(endpoint, cred) packagePath = writeTeamsAppPackage( ctx, agentClient, args.Project, svc, agentName, subscriptionID, resourceGroup, botName,