From ca5fb4ca9a851c4fafcf59b662b044722d18a740 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 28 May 2026 15:21:53 +0800 Subject: [PATCH 01/12] feat(agents): opinionated defaults for azd ai agent init -m When users provide a manifest explicitly via -m flag or positional argument, apply opinionated defaults to minimize interactive prompts: - Deploy mode: auto-select 'code' for Python/.NET projects - Runtime: auto-detect from project files (requirements.txt python_3_13, .csproj dotnet_10) - Entry point: auto-detect using existing detectDefaultEntryPoint logic - Dependencies: default to remote_build - Model confirmation: skip 'Use from manifest / Choose different' prompt - Model deployment: auto-select if exactly one matching deployment exists - Container resources: auto-select default tier (0.5 cores, 1Gi) This reduces the -m quickstart from 8+ prompts to 2 (subscription + project). All auto-resolved values are printed with a prefix so users see what was chosen. The existing interactive init (without -m) and --no-prompt mode are completely unchanged. The userProvidedManifest bool is captured before auto-detection can set manifestPointer, ensuring only explicit user intent triggers the streamlined flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init.go | 61 +++++++++---- .../internal/cmd/init_from_code.go | 32 +++++-- .../internal/cmd/init_from_code_test.go | 88 +++++++++++++++---- .../internal/cmd/init_models.go | 22 ++++- 4 files changed, 161 insertions(+), 42 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 ed07552fe44..a2c506ff263 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -95,6 +95,11 @@ type InitAction struct { httpClient *http.Client serviceNameOverride string // when set, addToProject uses this instead of the manifest name createdFolderDisplay string // pre-computed relative display path for the created folder + + // userProvidedManifest is true when the user explicitly provided a manifest via + // the -m flag or positional argument (not auto-detected from the working directory). + // When true, the init flow applies opinionated defaults to minimize interactive prompts. + userProvidedManifest bool } // modelSelector encapsulates the dependencies needed for model selection and @@ -108,15 +113,17 @@ type modelSelector struct { modelCatalog map[string]*azdext.AiModel locationWarningShown bool + userProvidedManifest bool } func (a *InitAction) getModelSelector() *modelSelector { if a.models == nil { a.models = &modelSelector{ - azdClient: a.azdClient, - azureContext: a.azureContext, - environment: a.environment, - flags: a.flags, + azdClient: a.azdClient, + azureContext: a.azureContext, + environment: a.environment, + flags: a.flags, + userProvidedManifest: a.userProvidedManifest, } } return a.models @@ -542,6 +549,7 @@ func runInitFromManifest( httpClient *http.Client, targetDir string, createdFolderDisplay string, + userProvidedManifest bool, ) error { // Ensure project and environment exist (no subscription/location prompting yet) projectConfig, err := ensureProject(ctx, flags, azdClient, targetDir) @@ -603,6 +611,7 @@ func runInitFromManifest( flags: flags, httpClient: httpClient, createdFolderDisplay: createdFolderDisplay, + userProvidedManifest: userProvidedManifest, } return action.Run(ctx) @@ -654,6 +663,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } + // Capture whether the user explicitly provided a manifest (via -m flag + // or positional argument) BEFORE the auto-detection logic below may also + // set flags.manifestPointer. This drives the opinionated-defaults path. + userProvidedManifest := flags.manifestPointer != "" + ctx := azdext.WithAccessToken(cmd.Context()) azdClient, err := azdext.NewAzdClient() @@ -781,7 +795,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, return err } - if err := runInitFromManifest(ctx, flags, azdClient, httpClient, ".", ""); err != nil { + if err := runInitFromManifest(ctx, flags, azdClient, httpClient, ".", "", userProvidedManifest); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } @@ -889,7 +903,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if manifestPath != "" { flags.manifestPointer = manifestPath if err := runInitFromManifest( - ctx, flags, azdClient, httpClient, ".", folderDisplay, + ctx, flags, azdClient, httpClient, ".", folderDisplay, false, ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") @@ -914,7 +928,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } flags.manifestPointer = selectedTemplate.Source if err := runInitFromManifest( - ctx, flags, azdClient, httpClient, folderName, folderDisplay, + ctx, flags, azdClient, httpClient, folderName, folderDisplay, false, ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") @@ -1040,7 +1054,7 @@ func (a *InitAction) Run(ctx context.Context) error { // Code deploy is supported for Python and .NET projects. if _, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { showCodeDeploy := isPythonProject(targetDir) || isDotnetProject(targetDir) - deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode) + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode, a.userProvidedManifest) if err != nil { return fmt.Errorf("prompting for deploy mode: %w", err) } @@ -1052,7 +1066,7 @@ func (a *InitAction) Run(ctx context.Context) error { runtime: a.flags.runtime, entryPoint: a.flags.entryPoint, depResolution: a.flags.depResolution, - }) + }, a.userProvidedManifest) if err != nil { return fmt.Errorf("prompting for code configuration: %w", err) } @@ -2568,14 +2582,6 @@ func (a *InitAction) populateContainerSettings( ctx context.Context, manifestResources *agent_yaml.ContainerResources, ) (*project.ContainerSettings, error) { - choices := make([]*azdext.SelectChoice, len(project.ResourceTiers)) - for i, t := range project.ResourceTiers { - choices[i] = &azdext.SelectChoice{ - Label: t.String(), - Value: fmt.Sprintf("%d", i), - } - } - defaultIndex := int32(0) if manifestResources != nil { for i, t := range project.ResourceTiers { @@ -2586,6 +2592,27 @@ func (a *InitAction) populateContainerSettings( } } + // When the user provided a manifest explicitly (-m), auto-select the default + // resource tier without prompting to minimize interactive steps. + if a.userProvidedManifest { + selected := project.ResourceTiers[defaultIndex] + fmt.Printf(" %s Compute: %s (default)\n", output.WithSuccessFormat("✓"), selected.String()) + return &project.ContainerSettings{ + Resources: &project.ResourceSettings{ + Memory: selected.Memory, + Cpu: selected.Cpu, + }, + }, nil + } + + choices := make([]*azdext.SelectChoice, len(project.ResourceTiers)) + for i, t := range project.ResourceTiers { + choices[i] = &azdext.SelectChoice{ + Label: t.String(), + Value: fmt.Sprintf("%d", i), + } + } + resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ Message: "Select resources (CPU and Memory) for your agent. You can adjust these settings later in the azure.yaml file if needed.", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 1a80590966f..edebdd29875 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -613,7 +613,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) srcDir, _ = os.Getwd() } showCodeDeploy := isPythonProject(srcDir) || isDotnetProject(srcDir) - deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode) + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode, false) if err != nil { return nil, err } @@ -1200,7 +1200,7 @@ func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context, srcDir runtime: a.flags.runtime, entryPoint: a.flags.entryPoint, depResolution: a.flags.depResolution, - }) + }, false) } // protocolInfo pairs a protocol name with the default version used when generating agent.yaml. @@ -1330,7 +1330,7 @@ func knownProtocolNames() string { // When deployModeFlag is set, it is used directly (for --no-prompt with explicit flag). // When noPrompt is true and no flag is provided, defaults to "container" for backward compatibility. // When showCodeDeploy is false and no explicit flag overrides, code deploy is not offered. -func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool, showCodeDeploy bool, deployModeFlag string) (string, error) { +func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool, showCodeDeploy bool, deployModeFlag string, userProvidedManifest bool) (string, error) { // Explicit flag takes precedence if deployModeFlag != "" { switch deployModeFlag { @@ -1349,6 +1349,13 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt return "container", nil } + // When the user provided a manifest explicitly (-m) and the project supports + // code deploy, auto-select code deploy without prompting. + if userProvidedManifest { + fmt.Printf(" %s Deploy mode: Source Code (ZIP upload) — project supports code deploy\n", output.WithSuccessFormat("✓")) + return "code", nil + } + if noPrompt { return "container", nil } @@ -1437,7 +1444,7 @@ type codeDeployOptions struct { // promptCodeConfig prompts for code deploy configuration (runtime, entry point, // dependency resolution). When noPrompt is true, flags or defaults are used without prompting. -func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir string, noPrompt bool, opts codeDeployOptions) (*agent_yaml.CodeConfiguration, error) { +func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir string, noPrompt bool, opts codeDeployOptions, userProvidedManifest bool) (*agent_yaml.CodeConfiguration, error) { if srcDir == "" { srcDir = "." } @@ -1468,11 +1475,14 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s var runtime string if opts.runtime != "" { runtime = opts.runtime - } else if noPrompt { + } else if noPrompt || userProvidedManifest { if isDotnet && !isPython { runtime = "dotnet_10" } else { - runtime = "python_3_13" // default to Python for mixed/unknown repos (language preference, not version compat) + runtime = "python_3_13" + } + if userProvidedManifest { + fmt.Printf(" %s Runtime: %s — auto-detected\n", output.WithSuccessFormat("✓"), runtime) } } else { defaultIdx := int32(0) // First item in the filtered list @@ -1498,8 +1508,11 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s var entryPoint string if opts.entryPoint != "" { entryPoint = opts.entryPoint - } else if noPrompt { + } else if noPrompt || userProvidedManifest { entryPoint = defaultEntryPoint + if userProvidedManifest { + fmt.Printf(" %s Entry point: %s — auto-detected\n", output.WithSuccessFormat("✓"), entryPoint) + } } else { entryPointResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ @@ -1525,8 +1538,11 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s var depResolution string if opts.depResolution != "" { depResolution = opts.depResolution - } else if noPrompt { + } else if noPrompt || userProvidedManifest { depResolution = "remote_build" + if userProvidedManifest { + fmt.Printf(" %s Dependencies: remote_build (default)\n", output.WithSuccessFormat("✓")) + } } else { depDefaultIdx := int32(0) depResResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index 8ee9ec3a1a7..f47f22fc9be 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -892,13 +892,14 @@ func TestPromptDeployMode_FlagOverride(t *testing.T) { t.Parallel() tests := []struct { - name string - noPrompt bool - showCodeDeploy bool - flag string - want string - wantErr bool - wantErrContain string + name string + noPrompt bool + showCodeDeploy bool + flag string + userProvidedManifest bool + want string + wantErr bool + wantErrContain string }{ { name: "flag=container returns container", @@ -943,12 +944,36 @@ func TestPromptDeployMode_FlagOverride(t *testing.T) { flag: "", want: "container", }, + { + name: "userProvidedManifest + showCodeDeploy auto-selects code", + noPrompt: false, + showCodeDeploy: true, + flag: "", + userProvidedManifest: true, + want: "code", + }, + { + name: "userProvidedManifest + showCodeDeploy=false defaults to container", + noPrompt: false, + showCodeDeploy: false, + flag: "", + userProvidedManifest: true, + want: "container", + }, + { + name: "explicit flag overrides userProvidedManifest", + noPrompt: false, + showCodeDeploy: true, + flag: "container", + userProvidedManifest: true, + want: "container", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := promptDeployMode(t.Context(), nil, tt.noPrompt, tt.showCodeDeploy, tt.flag) + got, err := promptDeployMode(t.Context(), nil, tt.noPrompt, tt.showCodeDeploy, tt.flag, tt.userProvidedManifest) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") @@ -972,13 +997,14 @@ func TestPromptCodeConfig_FlagOverrides(t *testing.T) { t.Parallel() tests := []struct { - name string - files []string // files to create in temp dir - noPrompt bool - opts codeDeployOptions - wantRuntime string - wantEntry string - wantDepRes string + name string + files []string // files to create in temp dir + noPrompt bool + userProvidedManifest bool + opts codeDeployOptions + wantRuntime string + wantEntry string + wantDepRes string }{ { name: "all opts provided", @@ -1024,6 +1050,36 @@ func TestPromptCodeConfig_FlagOverrides(t *testing.T) { wantEntry: "app.py", wantDepRes: "remote_build", }, + { + name: "userProvidedManifest auto-detects python defaults", + files: []string{"requirements.txt", "app.py"}, + noPrompt: false, + userProvidedManifest: true, + opts: codeDeployOptions{}, + wantRuntime: "python_3_13", + wantEntry: "app.py", + wantDepRes: "remote_build", + }, + { + name: "userProvidedManifest auto-detects dotnet defaults", + files: []string{"MyAgent.csproj"}, + noPrompt: false, + userProvidedManifest: true, + opts: codeDeployOptions{}, + wantRuntime: "dotnet_10", + wantEntry: "MyAgent.dll", + wantDepRes: "remote_build", + }, + { + name: "opts override userProvidedManifest defaults", + files: []string{"requirements.txt", "app.py"}, + noPrompt: false, + userProvidedManifest: true, + opts: codeDeployOptions{runtime: "python_3_14", entryPoint: "bot.py", depResolution: "bundled"}, + wantRuntime: "python_3_14", + wantEntry: "bot.py", + wantDepRes: "bundled", + }, } for _, tt := range tests { @@ -1036,7 +1092,7 @@ func TestPromptCodeConfig_FlagOverrides(t *testing.T) { } } - got, err := promptCodeConfig(t.Context(), nil, dir, tt.noPrompt, tt.opts) + got, err := promptCodeConfig(t.Context(), nil, dir, tt.noPrompt, tt.opts, tt.userProvidedManifest) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index ee028907f6c..ecb8d872d59 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -199,6 +199,26 @@ func (a *InitAction) getModelDeploymentDetails( } if len(matchingDeployments) > 0 { + // When the user provided a manifest (-m) and there's exactly one match, + // auto-select it without prompting. + if a.userProvidedManifest && len(matchingDeployments) == 1 { + for name, deployment := range matchingDeployments { + fmt.Printf(" %s Model: %s — using existing deployment '%s'\n", output.WithSuccessFormat("✓"), model.Id, name) + return &project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: model.Id, + Format: deployment.ModelFormat, + Version: deployment.Version, + }, + Sku: project.DeploymentSku{ + Name: deployment.SkuName, + Capacity: deployment.SkuCapacity, + }, + }, false, nil + } + } + fmt.Printf("In your Microsoft Foundry project, found %d existing model deployment(s) matching your model %s.\n", len(matchingDeployments), model.Id) var options []string @@ -360,7 +380,7 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( return nil, fmt.Errorf("no model selected, exiting") } model = selectedModel - } else if !a.flags.noPrompt { + } else if !a.flags.noPrompt && !a.userProvidedManifest { // Model found in catalog — let user confirm or choose a different one choices := []*azdext.SelectChoice{ {Label: fmt.Sprintf("Use '%s' (from manifest)", model.Name), Value: "keep"}, From 941278880ccd1cce2edd57603512a5bea1cb3332 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 28 May 2026 16:00:13 +0800 Subject: [PATCH 02/12] fix: add output for model confirm and version in deployment auto-select - Print ' Model: (from manifest)' when skipping model confirm prompt - Include version in deployment auto-select message for user clarity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/init_models.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index ecb8d872d59..80db0682265 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -203,7 +203,7 @@ func (a *InitAction) getModelDeploymentDetails( // auto-select it without prompting. if a.userProvidedManifest && len(matchingDeployments) == 1 { for name, deployment := range matchingDeployments { - fmt.Printf(" %s Model: %s — using existing deployment '%s'\n", output.WithSuccessFormat("✓"), model.Id, name) + fmt.Printf(" %s Model deployment: %s (version: %s) — using existing deployment '%s'\n", output.WithSuccessFormat("✓"), model.Id, deployment.Version, name) return &project.Deployment{ Name: name, Model: project.DeploymentModel{ @@ -380,7 +380,10 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( return nil, fmt.Errorf("no model selected, exiting") } model = selectedModel - } else if !a.flags.noPrompt && !a.userProvidedManifest { + } else if a.userProvidedManifest { + // Model found — auto-accept from manifest without prompting + fmt.Printf(" %s Model: %s (from manifest)\n", output.WithSuccessFormat("✓"), model.Name) + } else if !a.flags.noPrompt { // Model found in catalog — let user confirm or choose a different one choices := []*azdext.SelectChoice{ {Label: fmt.Sprintf("Use '%s' (from manifest)", model.Name), Value: "keep"}, From 2bff3785adf4fe2f48ce3212a59e220b2650bf73 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 28 May 2026 16:12:39 +0800 Subject: [PATCH 03/12] fix: skip no-match prompt for -m, add precedence doc, rename test - Auto-select 'deploy_new' when userProvidedManifest + no matching deployments (eliminates unnecessary prompt) - Document resolution precedence in promptDeployMode comment - Rename misleading test name to clarify what it actually tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_from_code.go | 7 +++++++ .../azure.ai.agents/internal/cmd/init_from_code_test.go | 2 +- .../extensions/azure.ai.agents/internal/cmd/init_models.go | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index edebdd29875..92cb94762db 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -1331,6 +1331,13 @@ func knownProtocolNames() string { // When noPrompt is true and no flag is provided, defaults to "container" for backward compatibility. // When showCodeDeploy is false and no explicit flag overrides, code deploy is not offered. func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool, showCodeDeploy bool, deployModeFlag string, userProvidedManifest bool) (string, error) { + // Resolution precedence: + // 1. Explicit flag (--deploy-mode) — always wins + // 2. !showCodeDeploy — container is the only option (not Python/.NET) + // 3. userProvidedManifest (-m) — auto-select "code" (stronger signal) + // 4. noPrompt — "container" for backward compatibility (no signal) + // 5. Interactive prompt + // Explicit flag takes precedence if deployModeFlag != "" { switch deployModeFlag { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index f47f22fc9be..cde6fd63473 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -953,7 +953,7 @@ func TestPromptDeployMode_FlagOverride(t *testing.T) { want: "code", }, { - name: "userProvidedManifest + showCodeDeploy=false defaults to container", + name: "showCodeDeploy=false returns container regardless of userProvidedManifest", noPrompt: false, showCodeDeploy: false, flag: "", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index 80db0682265..749beb959fb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -257,7 +257,7 @@ func (a *InitAction) getModelDeploymentDetails( ) noMatchChoice := "deploy_new" - if !a.flags.noPrompt { + if !a.flags.noPrompt && !a.userProvidedManifest { noMatchChoices := []*azdext.SelectChoice{ { Label: fmt.Sprintf("Deploy a new '%s' model to the selected Foundry project", model.Id), From 943635d90731846b07fd3fe1afa5bf4361d77b11 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 28 May 2026 16:16:16 +0800 Subject: [PATCH 04/12] fix: add output for no-match deploy-new, clarify container settings scope - Print checkmark when auto-selecting deploy_new for no-match scenario - Add comment explaining populateContainerSettings trigger conditions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 4 ++++ .../extensions/azure.ai.agents/internal/cmd/init_models.go | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) 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 a2c506ff263..f2b7b9e33e4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2594,6 +2594,10 @@ func (a *InitAction) populateContainerSettings( // When the user provided a manifest explicitly (-m), auto-select the default // resource tier without prompting to minimize interactive steps. + // Note: In the primary quickstart path (Python/.NET + -m), deploy mode auto-selects + // "code" so this function is not reached. This branch triggers when: + // - showCodeDeploy=false (non-Python/non-.NET project → container mode) + // - User explicitly overrides with --deploy-mode container if a.userProvidedManifest { selected := project.ResourceTiers[defaultIndex] fmt.Printf(" %s Compute: %s (default)\n", output.WithSuccessFormat("✓"), selected.String()) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index 749beb959fb..37ddce3e2ed 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -257,7 +257,9 @@ func (a *InitAction) getModelDeploymentDetails( ) noMatchChoice := "deploy_new" - if !a.flags.noPrompt && !a.userProvidedManifest { + if a.userProvidedManifest { + fmt.Printf(" %s Model deployment: will deploy new '%s' — no existing deployment found\n", output.WithSuccessFormat("✓"), model.Id) + } else if !a.flags.noPrompt { noMatchChoices := []*azdext.SelectChoice{ { Label: fmt.Sprintf("Deploy a new '%s' model to the selected Foundry project", model.Id), From b5cf2d1612607239bee7febf7c7c15b5d11efc0a Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 28 May 2026 10:36:11 -0400 Subject: [PATCH 05/12] refactor: replace fmt.Printf with log.Printf for consistent logging in agent initialization --- .../azure.ai.agents/internal/cmd/init.go | 4 ++-- .../azure.ai.agents/internal/cmd/init_from_code.go | 14 ++++---------- .../azure.ai.agents/internal/cmd/init_models.go | 10 +++++----- 3 files changed, 11 insertions(+), 17 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 f2b7b9e33e4..e3aadc07308 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2178,7 +2178,7 @@ func writeAgentDefinitionFile(targetDir string, agentManifest *agent_yaml.AgentM return fmt.Errorf("saving file to %s: %w", filePath, err) } - fmt.Println(output.WithGrayFormat("Processed agent.yaml at %s", filePath)) + log.Printf("Processed agent.yaml at %s", filePath) // Generate .agentignore if it doesn't already exist agentIgnorePath := filepath.Join(targetDir, ".agentignore") @@ -2600,7 +2600,7 @@ func (a *InitAction) populateContainerSettings( // - User explicitly overrides with --deploy-mode container if a.userProvidedManifest { selected := project.ResourceTiers[defaultIndex] - fmt.Printf(" %s Compute: %s (default)\n", output.WithSuccessFormat("✓"), selected.String()) + log.Printf("Defaulted compute tier: %s", selected.String()) return &project.ContainerSettings{ Resources: &project.ResourceSettings{ Memory: selected.Memory, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 92cb94762db..d1412f1e70b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -1359,7 +1359,7 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt // When the user provided a manifest explicitly (-m) and the project supports // code deploy, auto-select code deploy without prompting. if userProvidedManifest { - fmt.Printf(" %s Deploy mode: Source Code (ZIP upload) — project supports code deploy\n", output.WithSuccessFormat("✓")) + log.Printf("Auto-selected deploy mode: code (project supports code deploy)") return "code", nil } @@ -1488,9 +1488,7 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s } else { runtime = "python_3_13" } - if userProvidedManifest { - fmt.Printf(" %s Runtime: %s — auto-detected\n", output.WithSuccessFormat("✓"), runtime) - } + log.Printf("Auto-detected runtime: %s", runtime) } else { defaultIdx := int32(0) // First item in the filtered list runtimeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ @@ -1517,9 +1515,7 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s entryPoint = opts.entryPoint } else if noPrompt || userProvidedManifest { entryPoint = defaultEntryPoint - if userProvidedManifest { - fmt.Printf(" %s Entry point: %s — auto-detected\n", output.WithSuccessFormat("✓"), entryPoint) - } + log.Printf("Auto-detected entry point: %s", entryPoint) } else { entryPointResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ @@ -1547,9 +1543,7 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s depResolution = opts.depResolution } else if noPrompt || userProvidedManifest { depResolution = "remote_build" - if userProvidedManifest { - fmt.Printf(" %s Dependencies: remote_build (default)\n", output.WithSuccessFormat("✓")) - } + log.Printf("Defaulted dependency resolution to remote_build") } else { depDefaultIdx := int32(0) depResResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index 37ddce3e2ed..d67eacbd3ff 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -203,7 +203,7 @@ func (a *InitAction) getModelDeploymentDetails( // auto-select it without prompting. if a.userProvidedManifest && len(matchingDeployments) == 1 { for name, deployment := range matchingDeployments { - fmt.Printf(" %s Model deployment: %s (version: %s) — using existing deployment '%s'\n", output.WithSuccessFormat("✓"), model.Id, deployment.Version, name) + log.Printf("Using existing model deployment: %s (version: %s, name: %s)", model.Id, deployment.Version, name) return &project.Deployment{ Name: name, Model: project.DeploymentModel{ @@ -258,7 +258,7 @@ func (a *InitAction) getModelDeploymentDetails( noMatchChoice := "deploy_new" if a.userProvidedManifest { - fmt.Printf(" %s Model deployment: will deploy new '%s' — no existing deployment found\n", output.WithSuccessFormat("✓"), model.Id) + log.Printf("Will deploy new model '%s' (no existing deployment found)", model.Id) } else if !a.flags.noPrompt { noMatchChoices := []*azdext.SelectChoice{ { @@ -383,8 +383,8 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( } model = selectedModel } else if a.userProvidedManifest { - // Model found — auto-accept from manifest without prompting - fmt.Printf(" %s Model: %s (from manifest)\n", output.WithSuccessFormat("✓"), model.Name) + // Model found - auto-accept from manifest without prompting + log.Printf("Using model from manifest: %s", model.Name) } else if !a.flags.noPrompt { // Model found in catalog — let user confirm or choose a different one choices := []*azdext.SelectChoice{ @@ -937,7 +937,7 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age log.Printf("warning: failed to update model_deployment provision signal: %v", err) } - fmt.Println("Model deployment details processed and injected into agent definition. Deployment details can also be found in the JSON formatted AI_PROJECT_DEPLOYMENTS environment variable.") + log.Println("Model deployment details processed and injected into agent definition. Deployment details can also be found in the JSON formatted AI_PROJECT_DEPLOYMENTS environment variable.") return updatedManifest, deploymentDetails, nil } From 96e8a84c370b246fc5ea991f1a4c6d60682ab0c2 Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 28 May 2026 12:20:23 -0400 Subject: [PATCH 06/12] feat(models): allow skipping model selection during deployment process --- .../azure.ai.agents/internal/cmd/init.go | 10 ++- .../internal/cmd/init_from_code.go | 4 +- .../internal/cmd/init_models.go | 68 +++++++++++++++++-- 3 files changed, 68 insertions(+), 14 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 e3aadc07308..07f9f6031d0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -113,17 +113,15 @@ type modelSelector struct { modelCatalog map[string]*azdext.AiModel locationWarningShown bool - userProvidedManifest bool } func (a *InitAction) getModelSelector() *modelSelector { if a.models == nil { a.models = &modelSelector{ - azdClient: a.azdClient, - azureContext: a.azureContext, - environment: a.environment, - flags: a.flags, - userProvidedManifest: a.userProvidedManifest, + azdClient: a.azdClient, + azureContext: a.azureContext, + environment: a.environment, + flags: a.flags, } } return a.models diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index d1412f1e70b..f9890ae1ffd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -970,7 +970,9 @@ func (a *InitFromCodeAction) resolveSelectedModelDeployment( flags: a.flags, } - return selector.getModelDetails(ctx, model.Name) + // allowSkip=false: in this recovery path the user already explicitly chose + // the model via selectNewModel earlier, so offering "Skip" would be confusing. + return selector.getModelDetails(ctx, model.Name, false) } // appendEnvVar appends an environment variable to a possibly-nil slice pointer, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index d67eacbd3ff..eaac4c05fdd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "errors" "fmt" "log" "slices" @@ -26,6 +27,13 @@ import ( var defaultSkuPriority = []string{"GlobalStandard", "DataZoneStandard", "Standard"} +// errModelSkipped is a sentinel error returned by getModelDetails when the +// user explicitly chooses "Skip this model" from the model-selection prompt. +// Callers MUST use errors.Is to detect this case and drop the model from the +// manifest rather than treating it as a failure. The resource is removed +// from manifest.Resources in ProcessModels so no deployment is provisioned. +var errModelSkipped = errors.New("user skipped model") + func (a *modelSelector) loadAiCatalog(ctx context.Context) error { if a.modelCatalog != nil { return nil @@ -330,8 +338,13 @@ func (a *InitAction) getModelDeploymentDetails( } } - modelDetails, err := a.getModelSelector().getModelDetails(ctx, model.Id) + modelDetails, err := a.getModelSelector().getModelDetails(ctx, model.Id, true) if err != nil { + if errors.Is(err, errModelSkipped) { + // Propagate the sentinel unwrapped so ProcessModels can detect + // the skip and drop the resource from manifest.Resources. + return nil, false, err + } return nil, false, fmt.Errorf("failed to get model details: %w", err) } @@ -367,7 +380,9 @@ func (a *InitAction) getModelDeploymentDetails( }, true, nil } -func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) (*azdext.AiModelDeployment, error) { +func (a *modelSelector) getModelDetails( + ctx context.Context, modelName string, allowSkip bool, +) (*azdext.AiModelDeployment, error) { if err := a.loadAiCatalog(ctx); err != nil { return nil, err } @@ -382,15 +397,20 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( return nil, fmt.Errorf("no model selected, exiting") } model = selectedModel - } else if a.userProvidedManifest { - // Model found - auto-accept from manifest without prompting - log.Printf("Using model from manifest: %s", model.Name) } else if !a.flags.noPrompt { - // Model found in catalog — let user confirm or choose a different one + // Model found in catalog -- let user confirm, choose a different one, + // or (when allowed) skip the model entirely. This is the standard + // selector for both interactively-detected manifests and the -m flow. choices := []*azdext.SelectChoice{ {Label: fmt.Sprintf("Use '%s' (from manifest)", model.Name), Value: "keep"}, {Label: "Choose a different model", Value: "change"}, } + if allowSkip { + choices = append(choices, &azdext.SelectChoice{ + Label: "Skip this model (do not deploy)", + Value: "skip", + }) + } defaultIdx := int32(0) resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ @@ -407,7 +427,8 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( return nil, fmt.Errorf("failed to prompt for model choice: %w", err) } - if choices[*resp.Value].Value == "change" { + switch choices[*resp.Value].Value { + case "change": selectedModel, err := a.promptModelFromCatalog(ctx) if err != nil { return nil, fmt.Errorf("failed to select alternative model: %w", err) @@ -416,6 +437,12 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( return nil, fmt.Errorf("no model selected, exiting") } model = selectedModel + case "skip": + fmt.Println(output.WithWarningFormat( + "Skipped model '%s'. The agent will not have a model deployed.", model.Name)) + fmt.Println(output.WithGrayFormat( + "Configure your agent's model manually before running 'azd provision'.")) + return nil, errModelSkipped } } @@ -882,6 +909,11 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age // branch was taken. anyModelProcessed := false anyNewDeployment := false + // skippedModelResources collects names of model resources the user + // explicitly chose to skip via the model-selection prompt. After the + // loop, these are dropped from manifest.Resources so no deployment is + // provisioned for them. + skippedModelResources := map[string]struct{}{} switch agentDef.Kind { case agent_yaml.AgentKindHosted: for _, resource := range manifest.Resources { @@ -900,6 +932,14 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age model := agent_yaml.Model{Id: resource.Id} modelDeployment, isNew, err := a.getModelDeploymentDetails(ctx, model) if err != nil { + if errors.Is(err, errModelSkipped) { + // User chose "Skip this model" in the selector. Drop + // the resource from manifest.Resources below so no + // deployment is provisioned. Don't touch the pending + // provision signal for this resource. + skippedModelResources[resource.Name] = struct{}{} + continue + } return nil, nil, fmt.Errorf("failed to get model deployment details: %w", err) } deploymentDetails = append(deploymentDetails, *modelDeployment) @@ -912,6 +952,20 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age } } + // Drop any model resources the user chose to skip so they aren't + // provisioned. Non-model resources and resources of other kinds are + // preserved unchanged. + if len(skippedModelResources) > 0 { + manifest.Resources = slices.DeleteFunc(manifest.Resources, func(r any) bool { + mr, ok := r.(agent_yaml.ModelResource) + if !ok { + return false + } + _, skipped := skippedModelResources[mr.Name] + return skipped + }) + } + updatedManifest, err := agent_yaml.InjectParameterValuesIntoManifest(manifest, paramValues) if err != nil { return nil, nil, fmt.Errorf("failed to inject deployment names into manifest: %w", err) From e72ff429a5930b50ad4e62376c9dc8899d46f9f0 Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 28 May 2026 14:00:57 -0400 Subject: [PATCH 07/12] refactor: default to container deploy not code deploy --- .../extensions/azure.ai.agents/internal/cmd/init.go | 8 +++----- .../azure.ai.agents/internal/cmd/init_from_code.go | 11 ++++++----- .../internal/cmd/init_from_code_test.go | 4 ++-- 3 files changed, 11 insertions(+), 12 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 07f9f6031d0..5879ab42744 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2591,11 +2591,9 @@ func (a *InitAction) populateContainerSettings( } // When the user provided a manifest explicitly (-m), auto-select the default - // resource tier without prompting to minimize interactive steps. - // Note: In the primary quickstart path (Python/.NET + -m), deploy mode auto-selects - // "code" so this function is not reached. This branch triggers when: - // - showCodeDeploy=false (non-Python/non-.NET project → container mode) - // - User explicitly overrides with --deploy-mode container + // resource tier without prompting to minimize interactive steps. In the + // primary -m quickstart path (Python/.NET), deploy mode auto-selects + // "container" so this function is reached for the default flow. if a.userProvidedManifest { selected := project.ResourceTiers[defaultIndex] log.Printf("Defaulted compute tier: %s", selected.String()) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index f9890ae1ffd..c23c0e4ca64 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -1336,7 +1336,7 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt // Resolution precedence: // 1. Explicit flag (--deploy-mode) — always wins // 2. !showCodeDeploy — container is the only option (not Python/.NET) - // 3. userProvidedManifest (-m) — auto-select "code" (stronger signal) + // 3. userProvidedManifest (-m) — auto-select "container" (opinionated default) // 4. noPrompt — "container" for backward compatibility (no signal) // 5. Interactive prompt @@ -1358,11 +1358,12 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt return "container", nil } - // When the user provided a manifest explicitly (-m) and the project supports - // code deploy, auto-select code deploy without prompting. + // When the user provided a manifest explicitly (-m), auto-select the + // opinionated default (container) without prompting. Users who want + // code deploy with -m can pass --deploy-mode code explicitly. if userProvidedManifest { - log.Printf("Auto-selected deploy mode: code (project supports code deploy)") - return "code", nil + log.Printf("Auto-selected deploy mode: container (use --deploy-mode code for code deploy)") + return "container", nil } if noPrompt { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index cde6fd63473..9384ea2623e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -945,12 +945,12 @@ func TestPromptDeployMode_FlagOverride(t *testing.T) { want: "container", }, { - name: "userProvidedManifest + showCodeDeploy auto-selects code", + name: "userProvidedManifest + showCodeDeploy auto-selects container", noPrompt: false, showCodeDeploy: true, flag: "", userProvidedManifest: true, - want: "code", + want: "container", }, { name: "showCodeDeploy=false returns container regardless of userProvidedManifest", From 03a359f4b2ada7ff3f5901d7c77679a06e536fb4 Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 28 May 2026 15:42:18 -0400 Subject: [PATCH 08/12] [azure.ai.agents] Fix 403 preview_feature_required on CreateAgentVersion PR #8347 switched hosted-agent management calls from api-version=2025-11-15-preview to api-version=v1. The preview API version had implicitly satisfied the Foundry preview-feature gate, so requests went through without an opt-in header. The v1 endpoint enforces the gate explicitly: POST /agents/{name}/versions with definition.kind=="hosted" now returns HTTP 403 preview_feature_required unless the caller sends 'Foundry-Features: HostedAgents=V1Preview'. Effect on users: 'azd deploy' fails for container (hosted) agents with the preview_feature_required error returned from the Foundry API. Confirmed via direct API probes against a live Foundry project: same URL/body/auth, only the api-version and header differ. api-version=2025-11-15-preview, no header -> 200 OK api-version=v1, no header -> 403 preview_feature_required api-version=v1, with header -> 200 OK Add the header inside CreateAgentVersion so all callers get the fix (service_target_agent.go deployHostedAgent and cmd/optimize_deploy.go). Scope: only CreateAgentVersion needs the change. Probed sibling v1 management calls (GetAgent, GetAgentVersion, ListAgents, ListAgentVersions, PatchAgent) against the same live project without the header - none returned 403. The write path that creates a hosted resource is the only one currently gated. Add a focused test that asserts CreateAgentVersion always sets the header, so this can't silently regress again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pkg/agents/agent_api/operations.go | 5 +++ .../pkg/agents/agent_api/operations_test.go | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index 86cf1fdf79e..a9e29b83118 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -363,6 +363,11 @@ func (c *AgentClient) CreateAgentVersion(ctx context.Context, agentName string, return nil, fmt.Errorf("failed to create request: %w", err) } + // Opt-in to the hosted-agents preview feature. The Foundry v1 endpoint + // gates POST /agents/{name}/versions with definition.kind=="hosted" behind + // this header and returns HTTP 403 (preview_feature_required) without it. + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { return nil, fmt.Errorf("failed to set request body: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index 945a17547fe..0bd745b8efc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -772,3 +772,39 @@ func TestZipDeployRequest_NoAgentNameHeader_OnUpdate(t *testing.T) { require.Equal(t, "CodeAgents=V1Preview,HostedAgents=V1Preview", transport.lastReq.Header.Get("Foundry-Features")) require.Equal(t, "sha", transport.lastReq.Header.Get("x-ms-code-zip-sha256")) } + +func TestCreateAgentVersion_SetsHostedAgentsPreviewHeader(t *testing.T) { + // The Foundry v1 endpoint gates POST /agents/{name}/versions on the + // HostedAgents=V1Preview opt-in header and returns 403 preview_feature_required + // without it. Make sure the client always sends the header so callers don't + // silently regress to the pre-v1 (preview-API-version) behavior. + versionResp := `{ + "object": "agent.version", + "id": "test-agent:1", + "name": "test-agent", + "version": "1" + }` + transport := &capturingTransport{statusCode: http.StatusCreated, respBody: versionResp} + client := newTestClient("https://test.example.com/api/projects/proj", transport) + + desc := "test desc" + req := &CreateAgentVersionRequest{Description: &desc} + + _, err := client.CreateAgentVersion(context.Background(), "test-agent", req, "v1") + require.NoError(t, err) + + require.NotNil(t, transport.lastReq, "expected request to be captured") + require.Equal(t, http.MethodPost, transport.lastReq.Method) + require.Equal( + t, + "https://test.example.com/api/projects/proj/agents/test-agent/versions", + transport.lastReq.URL.Scheme+"://"+transport.lastReq.URL.Host+transport.lastReq.URL.Path, + ) + require.Equal(t, "v1", transport.lastReq.URL.Query().Get("api-version")) + require.Equal( + t, + "HostedAgents=V1Preview", + transport.lastReq.Header.Get("Foundry-Features"), + "CreateAgentVersion must opt in to HostedAgents=V1Preview on the v1 endpoint", + ) +} From 3ae99ef5c4c6e3c40d66d842fd92be541b94e1fa Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 28 May 2026 16:12:50 -0400 Subject: [PATCH 09/12] fix(models): always prompt for existing-deployment match instead of silently auto-selecting When `azd ai agent init -m ` selected a Foundry project that already contained exactly one matching deployment for the manifest's model resource, the code silently auto-selected that deployment with no prompt. That made the model an invisible default and was inconsistent with the Use/Change/Skip selector shown everywhere else. This change replaces the silent path and the older "Found N existing deployment(s)... Create new model deployment" selector with a unified Use/Change/Skip-style prompt: - "Use existing deployment 'name' (version: X)" (one per matching deployment, sorted by name for deterministic ordering) - "Deploy a new model" (falls through to the existing deploy-new path which loads the model catalog) - "Skip this model (do not deploy)" (returns errModelSkipped; the resource is dropped from manifest.Resources by ProcessModels) The selector uses stable `SelectChoice.Value` strings ("use:", "deploy_new", "skip") so behavior is keyed off values, not labels. In `--no-prompt` mode the first sorted matching deployment is selected automatically (no prompt) so headless/CI flows continue to work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_models.go | 129 ++++++++++++------ 1 file changed, 87 insertions(+), 42 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index eaac4c05fdd..9e6178a2e20 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -207,56 +207,101 @@ func (a *InitAction) getModelDeploymentDetails( } if len(matchingDeployments) > 0 { - // When the user provided a manifest (-m) and there's exactly one match, - // auto-select it without prompting. - if a.userProvidedManifest && len(matchingDeployments) == 1 { - for name, deployment := range matchingDeployments { - log.Printf("Using existing model deployment: %s (version: %s, name: %s)", model.Id, deployment.Version, name) - return &project.Deployment{ - Name: name, - Model: project.DeploymentModel{ - Name: model.Id, - Format: deployment.ModelFormat, - Version: deployment.Version, - }, - Sku: project.DeploymentSku{ - Name: deployment.SkuName, - Capacity: deployment.SkuCapacity, - }, - }, false, nil - } + // Build a deterministically-ordered list of matching deployment names + // so options, defaults, and --no-prompt selection are stable across runs. + sortedNames := make([]string, 0, len(matchingDeployments)) + for name := range matchingDeployments { + sortedNames = append(sortedNames, name) + } + slices.Sort(sortedNames) + + // In --no-prompt mode, auto-select the first matching deployment + // deterministically so headless/CI flows don't block on a prompt. + if a.flags.noPrompt { + name := sortedNames[0] + deployment := matchingDeployments[name] + log.Printf( + "--no-prompt: using existing model deployment '%s' (version: %s) for model '%s'", + name, deployment.Version, model.Id, + ) + return &project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: model.Id, + Format: deployment.ModelFormat, + Version: deployment.Version, + }, + Sku: project.DeploymentSku{ + Name: deployment.SkuName, + Capacity: deployment.SkuCapacity, + }, + }, false, nil } - fmt.Printf("In your Microsoft Foundry project, found %d existing model deployment(s) matching your model %s.\n", len(matchingDeployments), model.Id) - - var options []string - for deploymentName := range matchingDeployments { - options = append(options, deploymentName) + // Interactive Use/Change/Skip-style selector that mirrors the + // standard manifest model prompt (init_models.go ~line 400). Each + // existing deployment becomes a "use:" option; "deploy_new" + // falls through to the new-deployment configuration path below; + // "skip" returns errModelSkipped so ProcessModels drops the model + // resource from the manifest. + choices := make([]*azdext.SelectChoice, 0, len(sortedNames)+2) + for _, name := range sortedNames { + d := matchingDeployments[name] + choices = append(choices, &azdext.SelectChoice{ + Value: "use:" + name, + Label: fmt.Sprintf("Use existing deployment '%s' (version: %s)", name, d.Version), + }) } - options = append(options, "Create new model deployment") + choices = append(choices, + &azdext.SelectChoice{Value: "deploy_new", Label: "Deploy a new model"}, + &azdext.SelectChoice{Value: "skip", Label: "Skip this model (do not deploy)"}, + ) - selection, err := a.selectFromList(ctx, "deployment", options, options[0]) + defaultIdx := int32(0) + resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: fmt.Sprintf( + "Found %d existing deployment(s) for model '%s' in the selected Foundry project. How would you like to proceed?", + len(sortedNames), model.Id, + ), + Choices: choices, + SelectedIndex: &defaultIdx, + }, + }) if err != nil { + if exterrors.IsCancellation(err) { + return nil, false, exterrors.Cancelled("model deployment selection was cancelled") + } return nil, false, fmt.Errorf("failed to select deployment: %w", err) } - if selection != "Create new model deployment" { - fmt.Printf("Using existing model deployment: %s\n", selection) - - if deployment, exists := matchingDeployments[selection]; exists { - return &project.Deployment{ - Name: selection, - Model: project.DeploymentModel{ - Name: model.Id, - Format: deployment.ModelFormat, - Version: deployment.Version, - }, - Sku: project.DeploymentSku{ - Name: deployment.SkuName, - Capacity: deployment.SkuCapacity, - }, - }, false, nil - } + selected := choices[*resp.Value].Value + switch { + case selected == "skip": + fmt.Println(output.WithWarningFormat( + "Skipped model '%s'. The agent will not have a model deployed.", model.Id)) + fmt.Println(output.WithGrayFormat( + "Configure your agent's model manually before running 'azd provision'.")) + return nil, false, errModelSkipped + case selected == "deploy_new": + // Fall through to the deploy-new logic below. + case strings.HasPrefix(selected, "use:"): + name := strings.TrimPrefix(selected, "use:") + deployment := matchingDeployments[name] + log.Printf("Using existing model deployment '%s' (version: %s) for model '%s'", + name, deployment.Version, model.Id) + return &project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: model.Id, + Format: deployment.ModelFormat, + Version: deployment.Version, + }, + Sku: project.DeploymentSku{ + Name: deployment.SkuName, + Capacity: deployment.SkuCapacity, + }, + }, false, nil } } else { color.Yellow( From abf968c4b8078ac6bf40a675e55be1dbaa8071cf Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 28 May 2026 17:29:02 -0700 Subject: [PATCH 10/12] Use opinionated defaults for all manifest paths Signed-off-by: trangevi --- .../extensions/azure.ai.agents/internal/cmd/init.go | 11 ++++++----- .../azure.ai.agents/internal/cmd/init_from_code.go | 3 ++- 2 files changed, 8 insertions(+), 6 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 5879ab42744..9f09e7e4ac7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -96,9 +96,10 @@ type InitAction struct { serviceNameOverride string // when set, addToProject uses this instead of the manifest name createdFolderDisplay string // pre-computed relative display path for the created folder - // userProvidedManifest is true when the user explicitly provided a manifest via - // the -m flag or positional argument (not auto-detected from the working directory). - // When true, the init flow applies opinionated defaults to minimize interactive prompts. + // userProvidedManifest is true when the init flow is driven by a manifest — + // either explicitly via the -m flag/positional argument, or when the user + // interactively selects a template that resolves to a manifest. When true, + // the init flow applies opinionated defaults to minimize interactive prompts. userProvidedManifest bool } @@ -901,7 +902,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if manifestPath != "" { flags.manifestPointer = manifestPath if err := runInitFromManifest( - ctx, flags, azdClient, httpClient, ".", folderDisplay, false, + ctx, flags, azdClient, httpClient, ".", folderDisplay, true, ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") @@ -926,7 +927,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } flags.manifestPointer = selectedTemplate.Source if err := runInitFromManifest( - ctx, flags, azdClient, httpClient, folderName, folderDisplay, false, + ctx, flags, azdClient, httpClient, folderName, folderDisplay, true, ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index c23c0e4ca64..822f1048749 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -1336,7 +1336,8 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt // Resolution precedence: // 1. Explicit flag (--deploy-mode) — always wins // 2. !showCodeDeploy — container is the only option (not Python/.NET) - // 3. userProvidedManifest (-m) — auto-select "container" (opinionated default) + // 3. userProvidedManifest — auto-select "container" (opinionated default; + // triggered by -m flag OR interactive template selection) // 4. noPrompt — "container" for backward compatibility (no signal) // 5. Interactive prompt From eb8b0b38866fd8b55d5f96587a8e824a54f9d59b Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 28 May 2026 21:50:31 -0400 Subject: [PATCH 11/12] feat(init): implement agent name resolution and manifest path handling --- .../azure.ai.agents/internal/cmd/init.go | 385 ++++++++++++++---- .../azure.ai.agents/internal/cmd/init_test.go | 356 ++++++++++++++++ 2 files changed, 664 insertions(+), 77 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 9f09e7e4ac7..e8b37648928 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -271,6 +271,54 @@ func resolveInitAgentName( return validateInitAgentName(agentName) } +// resolveAgentNameFromManifestPointer resolves the agent name BEFORE any +// project folder is created so the project folder, the agent identity, the +// service entry, the src subfolder, and the post-init "cd" hint all use the +// same name. +// +// Resolution order: +// - If --agent-name is set, that value is used (validated) and pinned. +// - Else, peek the manifest's `name` field to seed the prompt default. +// If peek succeeds, prompt the user (or use the default in --no-prompt mode) +// and pin the result. +// - Else (peek failed: unsupported URL form, parse error, missing name), +// return "" so the caller falls back to today's defer behavior, which +// leaves name resolution to the inner downloadAgentYaml flow once the +// fully-loaded manifest is available. +// +// When a name is resolved, flags.agentName is set so the inner +// resolveInitAgentName call short-circuits without re-prompting the user. +func resolveAgentNameFromManifestPointer( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifestPointer string, + httpClient *http.Client, +) (string, error) { + if flags.agentName != "" { + validated, err := validateInitAgentName(flags.agentName) + if err != nil { + return "", err + } + flags.agentName = validated + return validated, nil + } + + peeked := peekManifestName(ctx, manifestPointer, httpClient) + if peeked == "" { + // Defer to the inner flow which has access to the fully-loaded manifest. + return "", nil + } + + resolved, err := resolveInitAgentName(ctx, azdClient, flags, peeked) + if err != nil { + return "", err + } + // Pin so the inner resolveInitAgentName call is a no-op. + flags.agentName = resolved + return resolved, nil +} + func validateInitAgentName(name string) (string, error) { name = strings.TrimSpace(name) if err := agent_yaml.ValidateAgentName(name); err != nil { @@ -313,6 +361,39 @@ func setAgentNameOnTemplate(agentManifest *agent_yaml.AgentManifest, agentName s return nil } +// absolutizeRelativeManifestPaths converts the -m manifest pointer to absolute +// when it refers to a local path so it remains valid after ensureProject +// changes into a newly created project directory. URLs and already-absolute +// paths are left unchanged. Errors here are surfaced because they indicate a +// problem the user can fix (e.g. invalid pathname). +// +// Note: flags.src is intentionally left unchanged. It is the output target +// for the downloaded agent definition (defaults to src/ inside the +// project). InitAction.Run rewrites absolute --src values relative to the +// project root via filepath.Rel; converting a user-supplied relative --src +// to absolute before ensureProject changes into the new project folder would +// cause that rewrite to produce a "..\" path that escapes the project +// directory. +func absolutizeRelativeManifestPaths(flags *initFlags) error { + if flags.manifestPointer == "" { + return nil + } + if strings.HasPrefix(flags.manifestPointer, "http://") || + strings.HasPrefix(flags.manifestPointer, "https://") { + return nil + } + if filepath.IsAbs(flags.manifestPointer) { + return nil + } + + abs, err := filepath.Abs(flags.manifestPointer) + if err != nil { + return fmt.Errorf("resolve manifest path: %w", err) + } + flags.manifestPointer = abs + return nil +} + func folderNameStrippingParenSuffix(title string) string { if idx := strings.IndexByte(title, '('); idx >= 0 { title = strings.TrimSpace(title[:idx]) @@ -320,6 +401,160 @@ func folderNameStrippingParenSuffix(title string) string { return sanitizeAgentName(title) } +// peekManifestName makes a best-effort attempt to read just the top-level +// "name" field from an agent manifest at the given pointer. It is used by the +// -m flow to derive a project folder name before the full manifest is loaded +// inside InitAction.Run. Any failure (read error, parse error, missing name, +// unsupported pointer type) returns an empty string, leaving the caller to +// choose a conservative fallback. Errors are logged at debug level only — the +// authoritative download/parse happens later in downloadAgentYaml and surfaces +// the real diagnostic to the user. +// +// Supported pointer types: +// - local file paths (read via os.ReadFile) +// - GitHub URLs in the form recognized by parseGitHubUrlNaive (fetched via +// plain HTTP against the contents API, no `gh` CLI required) +// +// Other URL forms (private GitHub repos that need `gh` auth, non-GitHub URLs) +// return "" — the caller falls back to not creating a subdirectory in that +// case. +func peekManifestName(ctx context.Context, manifestPointer string, httpClient *http.Client) string { + if manifestPointer == "" { + return "" + } + + content, ok := readManifestContentForPeek(ctx, manifestPointer, httpClient) + if !ok { + return "" + } + + var head struct { + Name string `yaml:"name"` + } + if err := yaml.Unmarshal(content, &head); err != nil { + log.Printf("peek manifest name: parse: %v", err) + return "" + } + return strings.TrimSpace(head.Name) +} + +// readManifestContentForPeek returns the raw manifest bytes for a best-effort +// peek. It mirrors the local-file and naive-GitHub-URL fast paths of +// downloadAgentYaml without invoking the `gh` CLI. Returns ok=false on any +// failure or unsupported pointer type. +func readManifestContentForPeek( + ctx context.Context, manifestPointer string, httpClient *http.Client, +) ([]byte, bool) { + // Local file path: bypass URL handling entirely so a relative path like + // "agent.yaml" that happens to look URL-ish is still read from disk. + if !strings.HasPrefix(manifestPointer, "http://") && !strings.HasPrefix(manifestPointer, "https://") { + info, statErr := os.Stat(manifestPointer) + if statErr != nil || info.IsDir() { + return nil, false + } + //nolint:gosec // manifest path is an explicit user-provided local path; same trust model as downloadAgentYaml + content, err := os.ReadFile(manifestPointer) + if err != nil { + log.Printf("peek manifest name: read %s: %v", manifestPointer, err) + return nil, false + } + return content, true + } + + // GitHub URL: try the same naive parse + unauthenticated HTTP GET used + // inside downloadAgentYaml for public repositories. + urlInfo := parseGitHubUrlNaive(manifestPointer) + if urlInfo == nil { + return nil, false + } + if httpClient == nil { + return nil, false + } + + fileApiUrl := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s", urlInfo.RepoSlug, urlInfo.FilePath) + if urlInfo.Branch != "" { + fileApiUrl += "?ref=" + url.QueryEscape(urlInfo.Branch) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileApiUrl, nil) + if err != nil { + log.Printf("peek manifest name: request: %v", err) + return nil, false + } + req.Header.Set("Accept", "application/vnd.github.v3.raw") + + //nolint:gosec // URL is constrained to the GitHub contents API built from a parsed GitHub URL + resp, err := httpClient.Do(req) + if err != nil { + log.Printf("peek manifest name: http: %v", err) + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("peek manifest name: http status %d", resp.StatusCode) + return nil, false + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("peek manifest name: read body: %v", err) + return nil, false + } + return body, true +} + +// parseGitHubUrlNaive mirrors (*InitAction).parseGitHubUrlNaive for callers +// that do not have an InitAction available (e.g. peekManifestName, which runs +// before the action is constructed). The receiver-bound version is kept in +// place so the existing downloadAgentYaml code path is untouched. +func parseGitHubUrlNaive(manifestPointer string) *GitHubUrlInfo { + parsedURL, err := url.Parse(manifestPointer) + if err != nil { + return nil + } + + if parsedURL.Host == "github.com" && strings.Contains(parsedURL.Path, "/blob/") { + parts := strings.SplitN(parsedURL.Path, "/blob/", 2) + if len(parts) != 2 { + return nil + } + repoSlug := strings.TrimPrefix(parts[0], "/") + branch, filePath, ok := strings.Cut(parts[1], "/") + if !ok || strings.Contains(branch, "/") { + return nil + } + return &GitHubUrlInfo{ + RepoSlug: repoSlug, + Branch: branch, + FilePath: filePath, + Hostname: "github.com", + } + } + + if parsedURL.Host == "raw.githubusercontent.com" { + pathPart := strings.TrimPrefix(parsedURL.Path, "/") + parts := strings.SplitN(pathPart, "/", 3) + if len(parts) < 3 { + return nil + } + repoSlug := parts[0] + "/" + parts[1] + if rest, ok := strings.CutPrefix(parts[2], "refs/heads/"); ok { + branch, filePath, ok := strings.Cut(rest, "/") + if !ok || strings.Contains(branch, "/") { + return nil + } + return &GitHubUrlInfo{ + RepoSlug: repoSlug, + Branch: branch, + FilePath: filePath, + Hostname: "github.com", + } + } + } + + return nil +} + func updateAgentDefinition( template any, update func(*agent_yaml.AgentDefinition), @@ -794,7 +1029,50 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, return err } - if err := runInitFromManifest(ctx, flags, azdClient, httpClient, ".", "", userProvidedManifest); err != nil { + // Resolve the agent name BEFORE creating the project folder + // so the folder, the agent identity, the service entry, and + // the cd hint all use the same user-chosen name. Peeking the + // manifest seeds the prompt default; an explicit --agent-name + // flag wins outright. See resolveAgentNameFromManifestPointer. + resolvedName, err := resolveAgentNameFromManifestPointer( + ctx, azdClient, flags, flags.manifestPointer, httpClient, + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + + // Mirror the template flow (#8210) and create a project folder + // derived from the resolved agent name. When the peek failed + // AND no --agent-name was provided (resolvedName == ""), fall + // back to the prior behavior of initializing in the current + // directory so we never leave the user with an empty folder + + // starter project after a downloadAgentYaml failure. + targetDir := "." + var folderDisplay string + if resolvedName != "" { + folderName := sanitizeAgentName(resolvedName) + // Make a local relative manifest path absolute before + // ensureProject changes into the new project directory, + // otherwise downloadAgentYaml will look for the manifest + // in the wrong place. flags.src is left as-is (see + // absolutizeRelativeManifestPaths comment for why). + if err := absolutizeRelativeManifestPaths(flags); err != nil { + return err + } + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + targetDir = folderName + if newlyCreated && !existingProject { + folderDisplay = filepath.ToSlash(folderName) + } + } + + if err := runInitFromManifest( + ctx, flags, azdClient, httpClient, targetDir, folderDisplay, userProvidedManifest, + ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } @@ -915,8 +1193,31 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, default: // Agent manifest template - use existing -m flow. - // Create project in a new subdirectory derived from the template title. + flags.manifestPointer = selectedTemplate.Source + + // Resolve the agent name BEFORE creating the project + // folder so the folder, the agent identity, the service + // entry, and the cd hint all use the same user-chosen + // name. Peeking the template's manifest seeds the prompt + // default; --agent-name wins outright. + resolvedName, err := resolveAgentNameFromManifestPointer( + ctx, azdClient, flags, selectedTemplate.Source, httpClient, + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + + // Prefer the resolved agent name for the project folder. + // Fall back to the template title only when manifest peek + // failed (e.g. unsupported URL form) AND no --agent-name + // was provided. folderName := folderNameStrippingParenSuffix(selectedTemplate.Title) + if resolvedName != "" { + folderName = sanitizeAgentName(resolvedName) + } // Check whether the target directory already exists so we // only report "created" when a new directory was made. _, statErr := os.Stat(folderName) @@ -925,7 +1226,6 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if newlyCreated && !existingProject { folderDisplay = filepath.ToSlash(folderName) } - flags.manifestPointer = selectedTemplate.Source if err := runInitFromManifest( ctx, flags, azdClient, httpClient, folderName, folderDisplay, true, ); err != nil { @@ -2657,81 +2957,12 @@ func downloadGithubManifest( // Expected formats: // - https://github.com/{owner}/{repo}/blob/{branch}/{path} // - https://raw.githubusercontent.com/{owner}/{repo}/refs/heads/{branch}/{path} +// +// parseGitHubUrlNaive delegates to the package-level parseGitHubUrlNaive so the +// peek path (which runs before InitAction is constructed) and the full +// downloadAgentYaml path share the same parsing logic. func (a *InitAction) parseGitHubUrlNaive(manifestPointer string) *GitHubUrlInfo { - // Parse URL to properly handle query parameters and fragments - parsedURL, err := url.Parse(manifestPointer) - if err != nil { - return nil - } - - // Try parsing github.com/blob format: https://github.com/{owner}/{repo}/blob/{branch}/{path} - if parsedURL.Host == "github.com" && strings.Contains(parsedURL.Path, "/blob/") { - hostname := "github.com" - - // Split by /blob/ - parts := strings.SplitN(parsedURL.Path, "/blob/", 2) - if len(parts) != 2 { - return nil - } - - // Extract repo slug (owner/repo) from the first part - repoPath := strings.TrimPrefix(parts[0], "/") - repoSlug := repoPath - - branch, filePath, ok := strings.Cut(parts[1], "/") - if !ok { - return nil - } - - // Only use naive parsing if branch looks like a simple single word (no slashes) - if strings.Contains(branch, "/") { - return nil - } - - return &GitHubUrlInfo{ - RepoSlug: repoSlug, - Branch: branch, - FilePath: filePath, - Hostname: hostname, - } - } - - // Try parsing raw.githubusercontent.com format: https://raw.githubusercontent.com/{owner}/{repo}/refs/heads/{branch}/{path} - if parsedURL.Host == "raw.githubusercontent.com" { - hostname := "github.com" // API calls still use github.com - - // Remove leading slash from path - pathPart := strings.TrimPrefix(parsedURL.Path, "/") - - // Split path: {owner}/{repo}/refs/heads/{branch}/{file-path} - parts := strings.SplitN(pathPart, "/", 3) // owner, repo, rest - if len(parts) < 3 { - return nil - } - - repoSlug := parts[0] + "/" + parts[1] - rest := parts[2] - if rest, ok := strings.CutPrefix(rest, "refs/heads/"); ok { - branch, filePath, ok := strings.Cut(rest, "/") - if !ok { - return nil - } - - // Only use naive parsing if branch looks like a simple single word - if strings.Contains(branch, "/") { - return nil - } - - return &GitHubUrlInfo{ - RepoSlug: repoSlug, - Branch: branch, - FilePath: filePath, - Hostname: hostname, - } - } - } - - return nil + return parseGitHubUrlNaive(manifestPointer) } // parseGitHubUrl extracts repository information from various GitHub URL formats using extension framework diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index c4e4569fdac..2dbc6fa4d37 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -2584,3 +2584,359 @@ func TestFolderNameFromTitle(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// peekManifestName: best-effort manifest name extraction for -m folder +// creation (covers PR review — parity with template-flow folder creation) +// --------------------------------------------------------------------------- + +func TestPeekManifestName_LocalFile_WithName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: my-cool-agent\ndescription: hi\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "my-cool-agent" { + t.Errorf("peekManifestName = %q, want %q", got, "my-cool-agent") + } +} + +func TestPeekManifestName_LocalFile_WithoutName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("description: missing top-level name\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty", got) + } +} + +func TestPeekManifestName_LocalFile_EmptyName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: \" \"\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty (whitespace-only name)", got) + } +} + +func TestPeekManifestName_LocalFile_NonExistent(t *testing.T) { + t.Parallel() + got := peekManifestName(t.Context(), filepath.Join(t.TempDir(), "does-not-exist.yaml"), &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for missing file", got) + } +} + +func TestPeekManifestName_LocalFile_Directory(t *testing.T) { + t.Parallel() + // Pointing at a directory should not be treated as a manifest — full + // validation runs in checkNotDirectory; peek must not panic or return + // noise. + got := peekManifestName(t.Context(), t.TempDir(), &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for directory", got) + } +} + +func TestPeekManifestName_LocalFile_MalformedYAML(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: [unterminated\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for malformed yaml", got) + } +} + +func TestPeekManifestName_LocalFile_NestedFieldsIgnored(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + manifest := "" + + "name: nested-agent\n" + + "description: hi\n" + + "tools:\n" + + " - name: not-the-agent-name\n" + + " - name: also-not-it\n" + + "environment:\n" + + " - name: SOME_ENV\n" + + " value: x\n" + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "nested-agent" { + t.Errorf("peekManifestName = %q, want nested-agent (top-level only)", got) + } +} + +func TestPeekManifestName_EmptyPointer(t *testing.T) { + t.Parallel() + got := peekManifestName(t.Context(), "", &http.Client{}) + if got != "" { + t.Errorf("peekManifestName(\"\") = %q, want empty", got) + } +} + +// --------------------------------------------------------------------------- +// resolveAgentNameFromManifestPointer: validates that the agent name is +// resolved (via --agent-name flag or peek+default in noPrompt) BEFORE any +// project folder is created, so the folder / agent identity / service entry / +// src subfolder / cd hint all use the same name. +// --------------------------------------------------------------------------- + +func TestResolveAgentNameFromManifestPointer_FlagWinsWithoutPeek(t *testing.T) { + t.Parallel() + + flags := &initFlags{agentName: "flag-agent", noPrompt: true} + // Manifest pointer is intentionally unreachable to prove the flag short-circuits + // the peek entirely. + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, filepath.Join(t.TempDir(), "does-not-exist.yaml"), &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "flag-agent" { + t.Errorf("got name = %q, want %q", got, "flag-agent") + } + if flags.agentName != "flag-agent" { + t.Errorf("flags.agentName = %q, want pinned to flag value", flags.agentName) + } +} + +func TestResolveAgentNameFromManifestPointer_NoPromptUsesPeekedDefault(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: from-manifest\ndescription: hi\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + flags := &initFlags{noPrompt: true} + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, path, &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "from-manifest" { + t.Errorf("got name = %q, want %q", got, "from-manifest") + } + if flags.agentName != "from-manifest" { + t.Errorf("flags.agentName = %q, want pinned to peeked default", flags.agentName) + } +} + +func TestResolveAgentNameFromManifestPointer_PeekFailsReturnsEmpty(t *testing.T) { + t.Parallel() + + // Peek will fail (nonexistent local path, no flag). Helper must return "" + // and leave flags.agentName empty so the caller falls back to the deferred + // inner resolution. + flags := &initFlags{noPrompt: true} + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, filepath.Join(t.TempDir(), "does-not-exist.yaml"), &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("got name = %q, want empty when peek fails and no flag", got) + } + if flags.agentName != "" { + t.Errorf("flags.agentName = %q, want unchanged when peek fails", flags.agentName) + } +} + +func TestResolveAgentNameFromManifestPointer_InvalidFlagReturnsError(t *testing.T) { + t.Parallel() + + // Invalid flag value must surface a validation error rather than silently + // falling back to peek — the user explicitly asked for this name. + flags := &initFlags{agentName: "INVALID NAME with spaces", noPrompt: true} + _, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, filepath.Join(t.TempDir(), "ignored.yaml"), &http.Client{}, + ) + if err == nil { + t.Fatalf("expected validation error for invalid --agent-name, got nil") + } +} + +func TestResolveAgentNameFromManifestPointer_FlagPeekConsistency(t *testing.T) { + t.Parallel() + + // When --agent-name matches what peek would return, the resolved name and + // the flag value should agree and the manifest never needs to be read. + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: shared-name\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + flags := &initFlags{agentName: "shared-name", noPrompt: true} + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, path, &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "shared-name" { + t.Errorf("got name = %q, want %q", got, "shared-name") + } +} + +func TestPeekManifestName_NonGitHubURL(t *testing.T) { + t.Parallel() + // Non-GitHub URLs are unsupported by the naive peek path and must return + // "" (the caller then falls back to targetDir="."). + got := peekManifestName(t.Context(), "https://example.com/some/manifest.yaml", &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for non-GitHub URL", got) + } +} + +// --------------------------------------------------------------------------- +// absolutizeRelativeManifestPaths: ensures the -m manifest path survives +// ensureProject chdir into the newly created project directory. flags.src +// is intentionally NOT absolutized (see godoc on the helper for why). +// --------------------------------------------------------------------------- + +func TestAbsolutizeRelativeManifestPaths_RelativeLocalManifest(t *testing.T) { + tmp := t.TempDir() + t.Chdir(tmp) + + flags := &initFlags{ + manifestPointer: "agent.yaml", + src: "src", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if !filepath.IsAbs(flags.manifestPointer) { + t.Errorf("manifestPointer should be absolute, got %q", flags.manifestPointer) + } + // Regression guard: --src is an output target (where the agent + // definition is downloaded to, relative to the project root). + // Absolutizing it before ensureProject chdirs into the new project + // folder would cause InitAction.Run's filepath.Rel rewrite to produce + // "..\src", writing the agent definition outside the new project. + if flags.src != "src" { + t.Errorf("src should remain relative %q, got %q", "src", flags.src) + } +} + +func TestAbsolutizeRelativeManifestPaths_AbsoluteLocalUnchanged(t *testing.T) { + t.Parallel() + absManifest := filepath.Join(t.TempDir(), "agent.yaml") + + flags := &initFlags{ + manifestPointer: absManifest, + src: "src", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if flags.manifestPointer != absManifest { + t.Errorf("absolute manifestPointer should be unchanged, got %q", flags.manifestPointer) + } + if flags.src != "src" { + t.Errorf("src should be unchanged, got %q", flags.src) + } +} + +func TestAbsolutizeRelativeManifestPaths_URLPointerUnchanged(t *testing.T) { + t.Parallel() + const ghURL = "https://github.com/owner/repo/blob/main/agent.yaml" + flags := &initFlags{ + manifestPointer: ghURL, + src: "", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if flags.manifestPointer != ghURL { + t.Errorf("URL manifestPointer should be unchanged, got %q", flags.manifestPointer) + } +} + +func TestAbsolutizeRelativeManifestPaths_EmptyFields(t *testing.T) { + t.Parallel() + flags := &initFlags{} + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if flags.manifestPointer != "" { + t.Errorf("empty manifestPointer should remain empty, got %q", flags.manifestPointer) + } + if flags.src != "" { + t.Errorf("empty src should remain empty, got %q", flags.src) + } +} + +// TestAbsolutizeRelativeManifestPaths_SrcEscapeRegression specifically guards +// against the bug where absolutizing --src before chdir + relativization in +// InitAction.Run would produce "..\src" and write agent files outside the +// newly created project directory. +func TestAbsolutizeRelativeManifestPaths_SrcEscapeRegression(t *testing.T) { + originalCwd := t.TempDir() + t.Chdir(originalCwd) + + flags := &initFlags{ + manifestPointer: "agent.yaml", + src: "src", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + + // Simulate ensureProject creating + chdir'ing into the project folder. + projectDir := filepath.Join(originalCwd, "my-agent") + //nolint:gosec // test fixture directory permissions are intentional + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + t.Chdir(projectDir) + + // Mirror what InitAction.Run does: relativize absolute src against the + // project root. This must NOT escape the project. + if filepath.IsAbs(flags.src) { + rel, err := filepath.Rel(projectDir, flags.src) + if err != nil { + t.Fatalf("filepath.Rel: %v", err) + } + if strings.HasPrefix(rel, "..") { + t.Fatalf("src rewrite escapes project: %q (was abs %q)", rel, flags.src) + } + } + // Even simpler: src should never have been touched in the first place. + if flags.src != "src" { + t.Errorf("src must remain relative %q to stay inside project, got %q", "src", flags.src) + } +} From 0241f4265ea35b4444ce7cf6a8a1ea9416d2b54b Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Fri, 29 May 2026 10:41:58 +0800 Subject: [PATCH 12/12] refactor: remove parseGitHubUrlNaive method wrapper, use package-level function directly Per review feedback (trangevi): the InitAction.parseGitHubUrlNaive() method was just a passthrough to the package-level function. Remove the wrapper and call the package-level function directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init.go | 15 +-------------- .../azure.ai.agents/internal/cmd/init_test.go | 3 +-- 2 files changed, 2 insertions(+), 16 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 e8b37648928..92efe7d3f48 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2263,7 +2263,7 @@ func (a *InitAction) downloadAgentYaml( var contentStr string // First try naive parsing assuming branch is a single word. This allows users to not have to authenticate // with gh CLI for public repositories. - urlInfo = a.parseGitHubUrlNaive(manifestPointer) + urlInfo = parseGitHubUrlNaive(manifestPointer) if urlInfo != nil { // Construct GitHub Contents API URL with ref query parameter fileApiUrl := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s", urlInfo.RepoSlug, urlInfo.FilePath) @@ -2952,19 +2952,6 @@ func downloadGithubManifest( return content, nil } -// parseGitHubUrlNaive attempts to parse a GitHub URL assuming a simple single-word branch name. -// Returns nil if the URL doesn't match the expected pattern. -// Expected formats: -// - https://github.com/{owner}/{repo}/blob/{branch}/{path} -// - https://raw.githubusercontent.com/{owner}/{repo}/refs/heads/{branch}/{path} -// -// parseGitHubUrlNaive delegates to the package-level parseGitHubUrlNaive so the -// peek path (which runs before InitAction is constructed) and the full -// downloadAgentYaml path share the same parsing logic. -func (a *InitAction) parseGitHubUrlNaive(manifestPointer string) *GitHubUrlInfo { - return parseGitHubUrlNaive(manifestPointer) -} - // parseGitHubUrl extracts repository information from various GitHub URL formats using extension framework func (a *InitAction) parseGitHubUrl(ctx context.Context, manifestPointer string) (*GitHubUrlInfo, error) { urlInfo, err := a.azdClient.Project().ParseGitHubUrl(ctx, &azdext.ParseGitHubUrlRequest{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 2dbc6fa4d37..1b1530821d9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -724,8 +724,7 @@ func TestParseGitHubUrlNaive(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - a := &InitAction{} - result := a.parseGitHubUrlNaive(tt.url) + result := parseGitHubUrlNaive(tt.url) if tt.expected == nil { if result != nil {