From d576207c358638aa9b1b45113de5dcaa04d66b33 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:52:57 +0800 Subject: [PATCH 01/34] Add invocations_ws agent init support --- .../internal/cmd/init_from_code.go | 1 + .../internal/cmd/init_from_code_test.go | 56 +++++++++++++++++++ .../pkg/agents/agent_yaml/map_test.go | 50 +++++++++++++++++ .../schemas/azure.ai.agent.json | 4 +- 4 files changed, 109 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 b538dc8e7e7..356cb524ddc 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 @@ -893,6 +893,7 @@ type protocolInfo struct { var knownProtocols = []protocolInfo{ {Name: "responses", Version: "2.0.0"}, {Name: "invocations", Version: "1.0.0"}, + {Name: "invocations_ws", Version: "2.0.0"}, } // promptProtocols asks the user which protocols their agent supports. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index fc5afa774d1..37dde9d2bbb 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 @@ -12,6 +12,7 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -597,6 +598,13 @@ func TestPromptProtocols_FlagValues(t *testing.T) { {Protocol: "invocations", Version: "1.0.0"}, }, }, + { + name: "invocations_ws only", + flagProtocols: []string{"invocations_ws"}, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + }, { name: "both protocols", flagProtocols: []string{"responses", "invocations"}, @@ -681,6 +689,9 @@ func TestKnownProtocolNames(t *testing.T) { if !strings.Contains(result, "invocations") { t.Errorf("knownProtocolNames() = %q, want to contain 'invocations'", result) } + if !strings.Contains(result, "invocations_ws") { + t.Errorf("knownProtocolNames() = %q, want to contain 'invocations_ws'", result) + } } // fakePromptClient is a lightweight test double for azdext.PromptServiceClient. @@ -718,6 +729,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: true}, {Value: "invocations", Label: "invocations", Selected: true}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -733,6 +745,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: true}, {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -740,6 +753,21 @@ func TestPromptProtocols_Interactive(t *testing.T) { {Protocol: "responses", Version: "2.0.0"}, }, }, + { + name: "websocket protocol selected", + multiSelectFn: func(_ context.Context, _ *azdext.MultiSelectRequest, _ ...grpc.CallOption) (*azdext.MultiSelectResponse, error) { + return &azdext.MultiSelectResponse{ + Values: []*azdext.MultiSelectChoice{ + {Value: "responses", Label: "responses", Selected: false}, + {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: true}, + }, + }, nil + }, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + }, { name: "user cancellation", multiSelectFn: func(_ context.Context, _ *azdext.MultiSelectRequest, _ ...grpc.CallOption) (*azdext.MultiSelectResponse, error) { @@ -755,6 +783,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: false}, {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -801,6 +830,33 @@ func TestPromptProtocols_Interactive(t *testing.T) { } } +func TestPromptProtocols_ChoicesIncludeInvocationsWsWithoutChangingDefault(t *testing.T) { + t.Parallel() + + client := &fakePromptClient{multiSelectFn: func( + _ context.Context, + in *azdext.MultiSelectRequest, + _ ...grpc.CallOption, + ) (*azdext.MultiSelectResponse, error) { + choices := in.Options.Choices + require.Len(t, choices, 3) + require.Equal(t, "responses", choices[0].Value) + require.True(t, choices[0].Selected) + require.Equal(t, "invocations", choices[1].Value) + require.False(t, choices[1].Selected) + require.Equal(t, "invocations_ws", choices[2].Value) + require.False(t, choices[2].Selected) + + return &azdext.MultiSelectResponse{Values: choices}, nil + }} + + got, err := promptProtocols(t.Context(), client, false, nil) + require.NoError(t, err) + require.Equal(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, got) +} + func TestPromptDeployMode_FlagOverride(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go index c9b35d0eceb..124b2d4ad91 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go @@ -4,6 +4,7 @@ package agent_yaml import ( + "encoding/json" "math" "strings" "testing" @@ -1604,6 +1605,55 @@ func TestCreateHostedAgentAPIRequest_WithRaiConfig(t *testing.T) { } } +func TestCreateAgentAPIRequest_CodeDeploy_InvocationsWsUsesProtocolVersions(t *testing.T) { + depRes := "remote_build" + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Name: "voice-agent", + Kind: AgentKindHosted, + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + CodeConfiguration: &CodeConfiguration{ + Runtime: "python_3_13", + EntryPoint: "main.py", + DependencyResolution: &depRes, + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + codeDef, ok := req.Definition.(agent_api.HostedAgentDefinition) + if !ok { + t.Fatalf("expected HostedAgentDefinition, got %T", req.Definition) + } + if len(codeDef.ProtocolVersions) != 1 { + t.Fatalf("got %d protocols, want 1", len(codeDef.ProtocolVersions)) + } + if codeDef.ProtocolVersions[0].Protocol != agent_api.AgentProtocolInvocationsWS { + t.Errorf("protocol = %q, want %q", codeDef.ProtocolVersions[0].Protocol, agent_api.AgentProtocolInvocationsWS) + } + if codeDef.ProtocolVersions[0].Version != "2.0.0" { + t.Errorf("version = %q, want %q", codeDef.ProtocolVersions[0].Version, "2.0.0") + } + + data, err := json.Marshal(codeDef) + if err != nil { + t.Fatalf("marshal: %v", err) + } + jsonText := string(data) + if !strings.Contains(jsonText, `"protocol_versions"`) { + t.Fatalf("serialized definition = %s, want protocol_versions", jsonText) + } + if strings.Contains(jsonText, `"container_protocol_versions"`) { + t.Fatalf("serialized definition = %s, did not expect container_protocol_versions", jsonText) + } +} + func TestCreateAgentAPIRequest_CodeDeploy_WithRaiConfig(t *testing.T) { t.Parallel() const raiPolicyID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index a6ac2d23f0d..1e362c72ed7 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -72,7 +72,7 @@ }, "protocols": { "type": "array", - "description": "Invocation protocols the agent implements (e.g., responses, invocations, a2a).", + "description": "Invocation protocols the agent implements (e.g., responses, invocations, invocations_ws, a2a).", "items": { "$ref": "#/definitions/ProtocolVersionRecord" } }, "agentEndpoint": { @@ -110,7 +110,7 @@ "type": "object", "description": "A protocol the agent implements, with its version.", "properties": { - "protocol": { "type": "string", "description": "Protocol name (e.g., 'responses', 'invocations', 'a2a')." }, + "protocol": { "type": "string", "description": "Protocol name (e.g., 'responses', 'invocations', 'invocations_ws', 'a2a')." }, "version": { "type": "string", "description": "Protocol version." } }, "required": ["protocol"], From 98934e029221de33d5faef28ebdd66fb9287bece Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:56:49 +0800 Subject: [PATCH 02/34] Update agent init protocol help --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 2 +- 1 file changed, 1 insertion(+), 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 82cab43c338..e29a0bf8e55 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1466,7 +1466,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Directory to download the agent definition to (defaults to 'src/')") cmd.Flags().StringSliceVar(&flags.protocols, "protocol", nil, - "Protocols supported by the agent (e.g., 'responses', 'invocations'). Can be specified multiple times.") + "Protocols supported by the agent (e.g., 'responses', 'invocations', 'invocations_ws'). Can be specified multiple times.") cmd.Flags().StringVar(&flags.deployMode, "deploy-mode", "", "Deployment mode: 'container' (Docker image) or 'code' (ZIP upload). Defaults to 'code' for Python/.NET projects in --no-prompt.") From ab39ee855ff2dd75e566a82839ea87994a1a25b1 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:26:44 +0800 Subject: [PATCH 03/34] Honor protocol flags for image agent init --- .../azure.ai.agents/internal/cmd/init.go | 49 +++++++++++++++++-- .../azure.ai.agents/internal/cmd/init_test.go | 38 +++++++++++++- 2 files changed, 81 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 e29a0bf8e55..320d7acaefc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -642,6 +642,38 @@ func preBuiltImageForInit(agentManifest *agent_yaml.AgentManifest, flagImage str return strings.TrimSpace(ca.Image) } +func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.ProtocolVersionRecord, error) { + if len(flagProtocols) == 0 { + return []agent_yaml.ProtocolVersionRecord{{Protocol: "responses", Version: "2.0.0"}}, nil + } + + versionOf := make(map[string]string, len(knownProtocols)) + for _, p := range knownProtocols { + versionOf[p.Name] = p.Version + } + + seen := make(map[string]bool, len(flagProtocols)) + records := make([]agent_yaml.ProtocolVersionRecord, 0, len(flagProtocols)) + for _, name := range flagProtocols { + if seen[name] { + continue + } + seen[name] = true + + version, ok := versionOf[name] + if !ok { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("unknown protocol %q; supported values: %s", name, knownProtocolNames()), + "choose one of the supported protocols or omit --protocol to use the default", + ) + } + records = append(records, agent_yaml.ProtocolVersionRecord{Protocol: name, Version: version}) + } + + return records, nil +} + // synthesizeImageManifestFile writes a minimal hosted container agent manifest to a // temporary file for the bring-your-own-image flow (--image without --manifest). // Routing through the manifest path lets init skip template/language selection and code @@ -649,8 +681,12 @@ func preBuiltImageForInit(agentManifest *agent_yaml.AgentManifest, flagImage str // the temp directory; callers should defer it. The image is intentionally not embedded // in the temporary manifest; init writes --image to the generated azure.yaml service's // top-level image field. -func synthesizeImageManifestFile(agentName, image string) (string, func(), error) { +func synthesizeImageManifestFile(agentName, image string, flagProtocols []string) (string, func(), error) { noop := func() {} + protocols, err := protocolRecordsForImageManifest(flagProtocols) + if err != nil { + return "", noop, err + } tmpDir, err := os.MkdirTemp("", "azd-agent-image-") if err != nil { @@ -658,15 +694,18 @@ func synthesizeImageManifestFile(agentName, image string) (string, func(), error } cleanup := func() { _ = os.RemoveAll(tmpDir) } + protocolDocs := make([]map[string]any, 0, len(protocols)) + for _, p := range protocols { + protocolDocs = append(protocolDocs, map[string]any{"protocol": p.Protocol, "version": p.Version}) + } + doc := map[string]any{ "name": agentName, "template": map[string]any{ "kind": string(agent_yaml.AgentKindHosted), "name": agentName, "description": fmt.Sprintf("Hosted container agent using pre-built image %s", image), - "protocols": []map[string]any{ - {"protocol": "responses", "version": "2.0.0"}, - }, + "protocols": protocolDocs, }, } @@ -1099,7 +1138,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "pass --agent-name (or provide --manifest with the agent definition)", ) } - manifestPath, cleanup, err := synthesizeImageManifestFile(flags.agentName, flags.image) + manifestPath, cleanup, err := synthesizeImageManifestFile(flags.agentName, flags.image, flags.protocols) if err != nil { return err } 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 039724aa77b..cc1f70b57f5 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 @@ -316,7 +316,7 @@ func TestSynthesizeImageManifestFile(t *testing.T) { const image = "myacr.azurecr.io/agents/my-agent@sha256:" + "76a9463463acf11d4068e8468fb232a3de0709177b6b35de95de6a34b33fa686" - manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image) + manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image, nil) require.NoError(t, err) require.NotNil(t, cleanup) require.FileExists(t, manifestPath) @@ -343,6 +343,42 @@ func TestSynthesizeImageManifestFile(t *testing.T) { require.NoFileExists(t, manifestPath) } +func TestSynthesizeImageManifestFile_UsesFlagProtocols(t *testing.T) { + t.Parallel() + + const agentName = "my-agent" + const image = "myacr.azurecr.io/agents/my-agent:v1" + + manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image, []string{"invocations_ws"}) + require.NoError(t, err) + defer cleanup() + + content, err := os.ReadFile(manifestPath) + require.NoError(t, err) + template, err := agent_yaml.ExtractAgentDefinition(content) + require.NoError(t, err) + + containerAgent, ok := template.(agent_yaml.ContainerAgent) + require.True(t, ok, "synthesized template should be a ContainerAgent, got %T", template) + require.Len(t, containerAgent.Protocols, 1) + require.Equal(t, "invocations_ws", containerAgent.Protocols[0].Protocol) + require.Equal(t, "2.0.0", containerAgent.Protocols[0].Version) +} + +func TestSynthesizeImageManifestFile_RejectsUnknownProtocol(t *testing.T) { + t.Parallel() + + manifestPath, cleanup, err := synthesizeImageManifestFile( + "my-agent", + "myacr.azurecr.io/agents/my-agent:v1", + []string{"unknown"}, + ) + require.Error(t, err) + require.Empty(t, manifestPath) + cleanup() + require.Contains(t, err.Error(), "unknown protocol") +} + func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { const image = "myacr.azurecr.io/agents/my-agent:v1" server := &recordingProjectServer{} From 4020370dbf1539f16d488223310dfbde833c90ae Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:27:52 +0800 Subject: [PATCH 04/34] Include fallback regions during agent init --- .../internal/cmd/hosted-agent-regions.json | 1 + .../internal/cmd/init_locations.go | 18 +++++++++++++++ .../internal/cmd/init_locations_test.go | 22 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json index 5589db957cf..9496ec0ad81 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json @@ -20,6 +20,7 @@ "switzerlandnorth", "uksouth", "westus", + "westus2", "westus3" ] } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index b8044b87208..66c7f4a763a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -99,6 +99,11 @@ func supportedRegionsForInit(ctx context.Context) ([]string, error) { func runRegionsFetch(ctx context.Context, fetch *regionsFetch) { // The fetch applies its own timeout (hostedAgentRegionsFetchTimeout). regions, err := fetchHostedAgentRegionsFromURL(ctx, http.DefaultClient, hostedAgentRegionsURL) + if err == nil { + if embedded, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(embedded) > 0 { + regions = mergeRegions(regions, embedded) + } + } if err != nil { if fallback, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(fallback) > 0 { @@ -119,6 +124,19 @@ func runRegionsFetch(ctx context.Context, fetch *regionsFetch) { close(fetch.done) } +func mergeRegions(primary []string, fallback []string) []string { + seen := make(map[string]bool, len(primary)+len(fallback)) + merged := make([]string, 0, len(primary)+len(fallback)) + for _, region := range append(primary, fallback...) { + if region == "" || seen[region] { + continue + } + seen[region] = true + merged = append(merged, region) + } + return merged +} + // parseEmbeddedHostedAgentRegions decodes the embedded build-time manifest used // as a fallback when the live fetch fails. func parseEmbeddedHostedAgentRegions() ([]string, error) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index 8562a1d3bc5..acc3414ee60 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -165,7 +165,7 @@ func TestSupportedRegionsForInit_FetchesOnceAndCaches(t *testing.T) { for range 3 { got, err := supportedRegionsForInit(t.Context()) require.NoError(t, err) - require.Equal(t, []string{"eastus2"}, got) + require.Contains(t, got, "eastus2") } require.Equal(t, 1, hits) } @@ -208,7 +208,7 @@ func TestSupportedRegionsForInit_ConcurrentCallersFetchOnce(t *testing.T) { defer wg.Done() got, err := supportedRegionsForInit(t.Context()) require.NoError(t, err) - require.Equal(t, []string{"eastus2"}, got) + require.Contains(t, got, "eastus2") }() } wg.Wait() @@ -237,6 +237,24 @@ func TestSupportedRegionsForInit_FallsBackToEmbeddedOnFetchError(t *testing.T) { require.Equal(t, want, got) } +func TestSupportedRegionsForInit_MergesFetchedAndEmbeddedRegions(t *testing.T) { + resetRegionsCache(t, nil) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"regions": ["eastus2"]}`)) + })) + t.Cleanup(server.Close) + + prev := hostedAgentRegionsURL + hostedAgentRegionsURL = server.URL + t.Cleanup(func() { hostedAgentRegionsURL = prev }) + + got, err := supportedRegionsForInit(t.Context()) + require.NoError(t, err) + require.Contains(t, got, "eastus2") + require.Contains(t, got, "westus2") +} + func TestParseEmbeddedHostedAgentRegions_NotEmpty(t *testing.T) { t.Parallel() From c270d1458d140de4cd608eee7b16559d9c2301e5 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:09:11 +0800 Subject: [PATCH 05/34] Support noninteractive voice sample init --- .../azure.ai.agents/internal/cmd/init.go | 9 + .../internal/cmd/init_adopt.go | 180 +++++++++++++++++- .../cmd/init_foundry_resources_helpers.go | 29 ++- .../internal/cmd/init_from_code.go | 3 + 4 files changed, 218 insertions(+), 3 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 320d7acaefc..f05c1c56454 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -51,6 +51,8 @@ import ( type initFlags struct { projectResourceId string + subscriptionId string + location string modelDeployment string model string manifestPointer string @@ -947,6 +949,9 @@ func runInitFromManifest( if err != nil { return err } + if err := applyAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { + return err + } // Create credential with whatever tenant is available (may be empty → default tenant) credential, err := azidentity.NewAzureDeveloperCLICredential( @@ -1488,6 +1493,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, cmd.Flags().StringVarP(&flags.projectResourceId, "project-id", "p", "", "Existing Microsoft Foundry Project Id to initialize your azd environment with") + cmd.Flags().StringVar(&flags.subscriptionId, "subscription", "", + "Azure subscription ID to use for initializing and provisioning the Foundry project") + cmd.Flags().StringVarP(&flags.location, "location", "l", "", + "Azure location to use for initializing and provisioning the Foundry project") cmd.Flags().StringVarP(&flags.modelDeployment, "model-deployment", "d", "", "Name of an existing model deployment to use from the Foundry project. Only used when paired with an existing Foundry project, either via --project-id or interactive prompts") 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 85f09bf6a42..2a54579c73f 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 @@ -19,6 +19,7 @@ import ( "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -109,8 +110,9 @@ type azureYamlServices struct { } type azureYamlService struct { - Host string `yaml:"host"` - Deployments []project.Deployment `yaml:"deployments"` + Host string `yaml:"host"` + Deployments []project.Deployment `yaml:"deployments"` + EnvironmentVariables []agent_yaml.EnvironmentVariable `yaml:"environmentVariables"` } // foundryDeployments parses the azure.yaml content and returns all model @@ -136,6 +138,131 @@ func foundryDeployments(content []byte) []foundryDeploymentEntry { return entries } +func firstFoundryProjectServiceName(content []byte) string { + var doc azureYamlServices + if err := yaml.Unmarshal(content, &doc); err != nil { + return "" + } + serviceNames := make([]string, 0, len(doc.Services)) + for svcName, svc := range doc.Services { + if svc.Host == "azure.ai.project" { + serviceNames = append(serviceNames, svcName) + } + } + slices.Sort(serviceNames) + if len(serviceNames) == 0 { + return "" + } + return serviceNames[0] +} + +func agentServicesWithEnvVar(content []byte, envName string) map[string][]agent_yaml.EnvironmentVariable { + var doc azureYamlServices + if err := yaml.Unmarshal(content, &doc); err != nil { + return nil + } + services := map[string][]agent_yaml.EnvironmentVariable{} + for svcName, svc := range doc.Services { + if svc.Host != "azure.ai.agent" { + continue + } + for _, envVar := range svc.EnvironmentVariables { + if envVar.Name == envName { + services[svcName] = svc.EnvironmentVariables + break + } + } + } + return services +} + +func agentServiceNames(content []byte) []string { + var doc azureYamlServices + if err := yaml.Unmarshal(content, &doc); err != nil { + return nil + } + var names []string + for svcName, svc := range doc.Services { + if svc.Host == "azure.ai.agent" { + names = append(names, svcName) + } + } + slices.Sort(names) + return names +} + +func updateAzureYamlAgentName(ctx context.Context, azdClient *azdext.AzdClient, serviceName, agentName string) error { + val, err := structpb.NewValue(agentName) + if err != nil { + return fmt.Errorf("encoding agent name for service %q: %w", serviceName, err) + } + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "name", + Value: val, + }); err != nil { + return fmt.Errorf("updating agent name in azure.yaml for service %q: %w", serviceName, err) + } + return nil +} + +func updateAzureYamlAgentEnvVar( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + envVars []agent_yaml.EnvironmentVariable, + envName string, + envValue string, +) error { + updated := make([]any, 0, len(envVars)) + for _, envVar := range envVars { + value := envVar.Value + if envVar.Name == envName { + value = envValue + } + updated = append(updated, map[string]any{"name": envVar.Name, "value": value}) + } + val, err := structpb.NewValue(updated) + if err != nil { + return fmt.Errorf("encoding env var %q for service %q: %w", envName, serviceName, err) + } + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "environmentVariables", + Value: val, + }); err != nil { + return fmt.Errorf("updating env var %q in azure.yaml for service %q: %w", envName, serviceName, err) + } + return nil +} + +func resolveDeploymentForModelFlag( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + modelName string, +) (*project.Deployment, error) { + if modelName == "" { + return nil, nil + } + deployment, err := resolveModelDeployment(ctx, azdClient, azureContext, &azdext.AiModel{Name: modelName}, azureContext.Scope.Location) + if err != nil { + return nil, err + } + return &project.Deployment{ + Name: deployment.ModelName, + Model: project.DeploymentModel{ + Name: deployment.ModelName, + Format: deployment.Format, + Version: deployment.Version, + }, + Sku: project.DeploymentSku{ + Name: deployment.Sku.Name, + Capacity: int(deployment.Capacity), + }, + }, nil +} + // verifyAzureYamlDeployments checks each model deployment declared in the // unified azure.yaml against the selected Foundry project's existing // deployments. It prompts the user for each deployment and returns the filtered @@ -768,6 +895,13 @@ func runInitFromAzureYaml( if err := ensureFoundryProviderDeclared(ctx, azdClient); err != nil { return err } + if flags.agentName != "" { + for _, agentServiceName := range agentServiceNames(content) { + if err := updateAzureYamlAgentName(ctx, azdClient, agentServiceName, flags.agentName); err != nil { + return err + } + } + } // --- Interactive Azure context setup (subscription, Foundry project) --- // The scaffolding created an environment; load it and run the same Foundry @@ -786,6 +920,9 @@ func runInitFromAzureYaml( if err != nil { return err } + if err := applyAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { + return err + } // Apply deploy-mode configuration to the adopted agent // service(s) before configuring the Foundry project. Whether an @@ -820,6 +957,45 @@ func runInitFromAzureYaml( // selected Foundry project. If the user opts to use existing deployments // or skip, we update the on-disk azure.yaml accordingly. deploymentEntries := foundryDeployments(content) + if len(deploymentEntries) == 0 && flags.modelDeployment != "" { + for agentServiceName, envVars := range agentServicesWithEnvVar(content, "AZURE_VOICELIVE_MODEL") { + if err := updateAzureYamlAgentEnvVar( + ctx, azdClient, agentServiceName, envVars, "AZURE_VOICELIVE_MODEL", flags.modelDeployment, + ); err != nil { + return err + } + } + if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", flags.modelDeployment); err != nil { + return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) + } + } + if len(deploymentEntries) == 0 && flags.model != "" && result != nil && result.Credential != nil { + serviceName := firstFoundryProjectServiceName(content) + if serviceName == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "sample azure.yaml does not declare an azure.ai.project service for model deployment", + "add an azure.ai.project service or omit --model", + ) + } + deployment, err := resolveDeploymentForModelFlag(ctx, azdClient, azureContext, flags.model) + if err != nil { + return fmt.Errorf("failed to resolve model deployment for %q: %w", flags.model, err) + } + if deployment != nil { + if err := updateAzureYamlDeployments(ctx, azdClient, serviceName, []project.Deployment{*deployment}); err != nil { + return err + } + for agentServiceName, envVars := range agentServicesWithEnvVar(content, "AZURE_VOICELIVE_MODEL") { + if err := updateAzureYamlAgentEnvVar( + ctx, azdClient, agentServiceName, envVars, "AZURE_VOICELIVE_MODEL", deployment.Name, + ); err != nil { + return err + } + } + deploymentEntries = []foundryDeploymentEntry{{ServiceName: serviceName, Deployment: *deployment}} + } + } if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { keptEntries, referencedDeployments, deploymentsModified, err := verifyAzureYamlDeployments( ctx, azdClient, result.Credential, azureContext, env.Name, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index bbd9e458c64..3c9991a3379 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -1076,6 +1076,34 @@ func ensureLocation( return setEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION", azureContext.Scope.Location) } +func applyAzureContextFlags( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + envName string, + flags *initFlags, +) error { + if flags == nil { + return nil + } + if flags.subscriptionId != "" { + azureContext.Scope.SubscriptionId = flags.subscriptionId + if err := setEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID", flags.subscriptionId); err != nil { + return err + } + } + if flags.location != "" { + azureContext.Scope.Location = flags.location + if err := setEnvValue(ctx, azdClient, envName, "AZURE_LOCATION", flags.location); err != nil { + return err + } + if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION", flags.location); err != nil { + return err + } + } + return nil +} + // ensureSubscriptionAndLocation ensures both subscription and location are set. // Returns the (possibly refreshed) credential. func ensureSubscriptionAndLocation( @@ -1213,7 +1241,6 @@ func resolveModelDeployments( ModelName: model.Name, Options: &azdext.AiModelDeploymentOptions{ Locations: []string{location}, - Capacity: new(defaultDeploymentCapacity), }, Quota: &azdext.QuotaCheckOptions{ MinRemainingCapacity: 1, 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 356cb524ddc..5847b1b0771 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 @@ -279,6 +279,9 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) } a.azureContext = azureContext } + if err := applyAzureContextFlags(ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags); err != nil { + return nil, err + } // TODO: Prompt user for agent kind agentKind := agent_yaml.AgentKindHosted From b07c5aba50612273107c5d4b934f824e76d55d9d Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:25:51 +0800 Subject: [PATCH 06/34] Add voice live term to spelling dictionary --- cli/azd/extensions/azure.ai.agents/cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 260983190ab..53e86eef85d 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -77,6 +77,7 @@ words: - underscoped - unparseable - Vnext + - VOICELIVE - webp # Doctor / next-step terms - nextstep From 9b1ee533ded92ace183b4dd19341fa5cc2908700 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:41:04 +0800 Subject: [PATCH 07/34] Remove voice sample env var coupling --- .../internal/cmd/init_adopt.go | 70 +------------------ 1 file changed, 2 insertions(+), 68 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 ef0b9f66053..4cbaaffc13a 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 @@ -19,7 +19,6 @@ import ( "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" - "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -110,9 +109,8 @@ type azureYamlServices struct { } type azureYamlService struct { - Host string `yaml:"host"` - Deployments []project.Deployment `yaml:"deployments"` - EnvironmentVariables []agent_yaml.EnvironmentVariable `yaml:"environmentVariables"` + Host string `yaml:"host"` + Deployments []project.Deployment `yaml:"deployments"` } // foundryDeployments parses the azure.yaml content and returns all model @@ -156,26 +154,6 @@ func firstFoundryProjectServiceName(content []byte) string { return serviceNames[0] } -func agentServicesWithEnvVar(content []byte, envName string) map[string][]agent_yaml.EnvironmentVariable { - var doc azureYamlServices - if err := yaml.Unmarshal(content, &doc); err != nil { - return nil - } - services := map[string][]agent_yaml.EnvironmentVariable{} - for svcName, svc := range doc.Services { - if svc.Host != "azure.ai.agent" { - continue - } - for _, envVar := range svc.EnvironmentVariables { - if envVar.Name == envName { - services[svcName] = svc.EnvironmentVariables - break - } - } - } - return services -} - func agentServiceNames(content []byte) []string { var doc azureYamlServices if err := yaml.Unmarshal(content, &doc); err != nil { @@ -206,36 +184,6 @@ func updateAzureYamlAgentName(ctx context.Context, azdClient *azdext.AzdClient, return nil } -func updateAzureYamlAgentEnvVar( - ctx context.Context, - azdClient *azdext.AzdClient, - serviceName string, - envVars []agent_yaml.EnvironmentVariable, - envName string, - envValue string, -) error { - updated := make([]any, 0, len(envVars)) - for _, envVar := range envVars { - value := envVar.Value - if envVar.Name == envName { - value = envValue - } - updated = append(updated, map[string]any{"name": envVar.Name, "value": value}) - } - val, err := structpb.NewValue(updated) - if err != nil { - return fmt.Errorf("encoding env var %q for service %q: %w", envName, serviceName, err) - } - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ - ServiceName: serviceName, - Path: "environmentVariables", - Value: val, - }); err != nil { - return fmt.Errorf("updating env var %q in azure.yaml for service %q: %w", envName, serviceName, err) - } - return nil -} - func resolveDeploymentForModelFlag( ctx context.Context, azdClient *azdext.AzdClient, @@ -967,13 +915,6 @@ func runInitFromAzureYaml( // or skip, we update the on-disk azure.yaml accordingly. deploymentEntries := foundryDeployments(content) if len(deploymentEntries) == 0 && flags.modelDeployment != "" { - for agentServiceName, envVars := range agentServicesWithEnvVar(content, "AZURE_VOICELIVE_MODEL") { - if err := updateAzureYamlAgentEnvVar( - ctx, azdClient, agentServiceName, envVars, "AZURE_VOICELIVE_MODEL", flags.modelDeployment, - ); err != nil { - return err - } - } if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", flags.modelDeployment); err != nil { return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) } @@ -995,13 +936,6 @@ func runInitFromAzureYaml( if err := updateAzureYamlDeployments(ctx, azdClient, serviceName, []project.Deployment{*deployment}); err != nil { return err } - for agentServiceName, envVars := range agentServicesWithEnvVar(content, "AZURE_VOICELIVE_MODEL") { - if err := updateAzureYamlAgentEnvVar( - ctx, azdClient, agentServiceName, envVars, "AZURE_VOICELIVE_MODEL", deployment.Name, - ); err != nil { - return err - } - } deploymentEntries = []foundryDeploymentEntry{{ServiceName: serviceName, Deployment: *deployment}} } } From cba283b0b50ca75636b9048eaaa75eb4b50630b4 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:05:54 +0800 Subject: [PATCH 08/34] Clarify sample adoption overrides --- .../internal/cmd/init_adopt.go | 22 ++++++++++++- .../internal/cmd/init_adopt_test.go | 31 +++++++++++++++++++ .../internal/cmd/init_locations.go | 3 ++ 3 files changed, 55 insertions(+), 1 deletion(-) 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 4cbaaffc13a..0514983d4c6 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 @@ -184,6 +184,22 @@ func updateAzureYamlAgentName(ctx context.Context, azdClient *azdext.AzdClient, return nil } +func agentNameOverrideServices(content []byte, agentName string) ([]string, error) { + if agentName == "" { + return nil, nil + } + agentServices := agentServiceNames(content) + if len(agentServices) > 1 { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--agent-name is ambiguous: sample declares %d agent services (%s)", + len(agentServices), strings.Join(agentServices, ", ")), + "remove --agent-name, or edit azure.yaml to rename each agent individually", + ) + } + return agentServices, nil +} + func resolveDeploymentForModelFlag( ctx context.Context, azdClient *azdext.AzdClient, @@ -844,7 +860,11 @@ func runInitFromAzureYaml( return err } if flags.agentName != "" { - for _, agentServiceName := range agentServiceNames(content) { + agentServices, err := agentNameOverrideServices(content, flags.agentName) + if err != nil { + return err + } + for _, agentServiceName := range agentServices { if err := updateAzureYamlAgentName(ctx, azdClient, agentServiceName, flags.agentName); err != nil { return err } 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 f89270b6150..f1a18343aa9 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 @@ -116,6 +116,37 @@ func TestFoundryProjectName(t *testing.T) { } } +func TestAgentNameOverrideServices_MultipleAgentsReturnsAmbiguousError(t *testing.T) { + content := []byte(`name: multi-agent +services: + first: + host: azure.ai.agent + name: first-agent + second: + host: azure.ai.agent + name: second-agent +`) + + services, err := agentNameOverrideServices(content, "shared-name") + require.Nil(t, services) + require.Error(t, err) + require.Contains(t, err.Error(), "--agent-name is ambiguous") + require.Contains(t, err.Error(), "first, second") +} + +func TestAgentNameOverrideServices_SingleAgentReturnsServiceName(t *testing.T) { + content := []byte(`name: one-agent +services: + first: + host: azure.ai.agent + name: first-agent +`) + + services, err := agentNameOverrideServices(content, "renamed") + require.NoError(t, err) + require.Equal(t, []string{"first"}, services) +} + func TestParentDirOf(t *testing.T) { tests := []struct { filePath string diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index 66c7f4a763a..34a4de17653 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -99,6 +99,9 @@ func supportedRegionsForInit(ctx context.Context) ([]string, error) { func runRegionsFetch(ctx context.Context, fetch *regionsFetch) { // The fetch applies its own timeout (hostedAgentRegionsFetchTimeout). regions, err := fetchHostedAgentRegionsFromURL(ctx, http.DefaultClient, hostedAgentRegionsURL) + // Even on a successful fetch, union the build-time embedded regions so newly + // added regions (for example westus2 for invocations_ws) remain selectable + // while the live manifest rolls out. mergeRegions dedups and preserves order. if err == nil { if embedded, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(embedded) > 0 { regions = mergeRegions(regions, embedded) From a62af9ca0f25c6b9ac4f61680f93c338f254cd43 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:22:39 +0800 Subject: [PATCH 09/34] Address sample adoption review feedback --- .../internal/cmd/init_adopt.go | 61 ++++++++++++++----- .../internal/cmd/init_adopt_test.go | 32 ++++++++++ .../internal/cmd/init_locations.go | 21 ------- .../internal/cmd/init_locations_test.go | 18 ------ 4 files changed, 77 insertions(+), 55 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 0514983d4c6..7a27f9c6c54 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 @@ -136,10 +136,14 @@ func foundryDeployments(content []byte) []foundryDeploymentEntry { return entries } -func firstFoundryProjectServiceName(content []byte) string { +func foundryProjectServiceForModelOverride(content []byte) (string, error) { var doc azureYamlServices if err := yaml.Unmarshal(content, &doc); err != nil { - return "" + return "", exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "sample azure.yaml could not be parsed", + "fix the sample manifest and run init again", + ) } serviceNames := make([]string, 0, len(doc.Services)) for svcName, svc := range doc.Services { @@ -148,10 +152,23 @@ func firstFoundryProjectServiceName(content []byte) string { } } slices.Sort(serviceNames) - if len(serviceNames) == 0 { - return "" + switch len(serviceNames) { + case 0: + return "", exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "sample azure.yaml does not declare an azure.ai.project service for model deployment", + "add an azure.ai.project service or omit --model", + ) + case 1: + return serviceNames[0], nil + default: + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--model is ambiguous: sample declares %d project services (%s)", + len(serviceNames), strings.Join(serviceNames, ", ")), + "remove --model, or edit azure.yaml to add the deployment to the intended project service", + ) } - return serviceNames[0] } func agentServiceNames(content []byte) []string { @@ -188,6 +205,9 @@ func agentNameOverrideServices(content []byte, agentName string) ([]string, erro if agentName == "" { return nil, nil } + if _, err := validateInitAgentName(agentName); err != nil { + return nil, err + } agentServices := agentServiceNames(content) if len(agentServices) > 1 { return nil, exterrors.Validation( @@ -934,20 +954,29 @@ func runInitFromAzureYaml( // selected Foundry project. If the user opts to use existing deployments // or skip, we update the on-disk azure.yaml accordingly. deploymentEntries := foundryDeployments(content) - if len(deploymentEntries) == 0 && flags.modelDeployment != "" { - if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", flags.modelDeployment); err != nil { - return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) - } - } - if len(deploymentEntries) == 0 && flags.model != "" && result != nil && result.Credential != nil { - serviceName := firstFoundryProjectServiceName(content) - if serviceName == "" { + if flags.modelDeployment != "" { + if result.FoundryProject == nil { return exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - "sample azure.yaml does not declare an azure.ai.project service for model deployment", - "add an azure.ai.project service or omit --model", + exterrors.CodeInvalidParameter, + "--model-deployment requires an existing Foundry project", + "provide --project-id with --model-deployment, or use --model to provision a new deployment", ) } + if len(deploymentEntries) == 0 { + serviceName, err := foundryProjectServiceForModelOverride(content) + if err != nil { + return err + } + deploymentEntries = []foundryDeploymentEntry{{ + ServiceName: serviceName, + Deployment: project.Deployment{Name: flags.modelDeployment}, + }} + } + } else if flags.model != "" && result != nil && result.Credential != nil { + serviceName, err := foundryProjectServiceForModelOverride(content) + if err != nil { + return err + } deployment, err := resolveDeploymentForModelFlag(ctx, azdClient, azureContext, flags.model) if err != nil { return fmt.Errorf("failed to resolve model deployment for %q: %w", flags.model, err) 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 f1a18343aa9..ab83b07947b 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 @@ -147,6 +147,38 @@ services: require.Equal(t, []string{"first"}, services) } +func TestFoundryProjectServiceForModelOverride_MultipleProjectsReturnsAmbiguousError(t *testing.T) { + content := []byte(`name: multi-project +services: + first-project: + host: azure.ai.project + second-project: + host: azure.ai.project + agent: + host: azure.ai.agent +`) + + serviceName, err := foundryProjectServiceForModelOverride(content) + require.Empty(t, serviceName) + require.Error(t, err) + require.Contains(t, err.Error(), "--model is ambiguous") + require.Contains(t, err.Error(), "first-project, second-project") +} + +func TestFoundryProjectServiceForModelOverride_SingleProjectReturnsServiceName(t *testing.T) { + content := []byte(`name: one-project +services: + ai-project: + host: azure.ai.project + agent: + host: azure.ai.agent +`) + + serviceName, err := foundryProjectServiceForModelOverride(content) + require.NoError(t, err) + require.Equal(t, "ai-project", serviceName) +} + func TestParentDirOf(t *testing.T) { tests := []struct { filePath string diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index 34a4de17653..b8044b87208 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -99,14 +99,6 @@ func supportedRegionsForInit(ctx context.Context) ([]string, error) { func runRegionsFetch(ctx context.Context, fetch *regionsFetch) { // The fetch applies its own timeout (hostedAgentRegionsFetchTimeout). regions, err := fetchHostedAgentRegionsFromURL(ctx, http.DefaultClient, hostedAgentRegionsURL) - // Even on a successful fetch, union the build-time embedded regions so newly - // added regions (for example westus2 for invocations_ws) remain selectable - // while the live manifest rolls out. mergeRegions dedups and preserves order. - if err == nil { - if embedded, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(embedded) > 0 { - regions = mergeRegions(regions, embedded) - } - } if err != nil { if fallback, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(fallback) > 0 { @@ -127,19 +119,6 @@ func runRegionsFetch(ctx context.Context, fetch *regionsFetch) { close(fetch.done) } -func mergeRegions(primary []string, fallback []string) []string { - seen := make(map[string]bool, len(primary)+len(fallback)) - merged := make([]string, 0, len(primary)+len(fallback)) - for _, region := range append(primary, fallback...) { - if region == "" || seen[region] { - continue - } - seen[region] = true - merged = append(merged, region) - } - return merged -} - // parseEmbeddedHostedAgentRegions decodes the embedded build-time manifest used // as a fallback when the live fetch fails. func parseEmbeddedHostedAgentRegions() ([]string, error) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index acc3414ee60..82ddbb16dfb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -237,24 +237,6 @@ func TestSupportedRegionsForInit_FallsBackToEmbeddedOnFetchError(t *testing.T) { require.Equal(t, want, got) } -func TestSupportedRegionsForInit_MergesFetchedAndEmbeddedRegions(t *testing.T) { - resetRegionsCache(t, nil) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"regions": ["eastus2"]}`)) - })) - t.Cleanup(server.Close) - - prev := hostedAgentRegionsURL - hostedAgentRegionsURL = server.URL - t.Cleanup(func() { hostedAgentRegionsURL = prev }) - - got, err := supportedRegionsForInit(t.Context()) - require.NoError(t, err) - require.Contains(t, got, "eastus2") - require.Contains(t, got, "westus2") -} - func TestParseEmbeddedHostedAgentRegions_NotEmpty(t *testing.T) { t.Parallel() From 3ed89397fcfe2aacf0d33c8bb4271512d1938774 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:57:09 +0800 Subject: [PATCH 10/34] Tighten sample adoption model overrides --- .../azure.ai.agents/internal/cmd/init_adopt.go | 15 +++++++++------ .../internal/cmd/init_adopt_test.go | 4 ++-- 2 files changed, 11 insertions(+), 8 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 7a27f9c6c54..289cc2becc3 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 @@ -136,7 +136,10 @@ func foundryDeployments(content []byte) []foundryDeploymentEntry { return entries } -func foundryProjectServiceForModelOverride(content []byte) (string, error) { +func foundryProjectServiceForModelOverride(content []byte, flagName string) (string, error) { + if flagName == "" { + flagName = "--model" + } var doc azureYamlServices if err := yaml.Unmarshal(content, &doc); err != nil { return "", exterrors.Validation( @@ -164,9 +167,9 @@ func foundryProjectServiceForModelOverride(content []byte) (string, error) { default: return "", exterrors.Validation( exterrors.CodeInvalidParameter, - fmt.Sprintf("--model is ambiguous: sample declares %d project services (%s)", - len(serviceNames), strings.Join(serviceNames, ", ")), - "remove --model, or edit azure.yaml to add the deployment to the intended project service", + fmt.Sprintf("%s is ambiguous: sample declares %d project services (%s)", + flagName, len(serviceNames), strings.Join(serviceNames, ", ")), + fmt.Sprintf("remove %s, or edit azure.yaml to add the deployment to the intended project service", flagName), ) } } @@ -963,7 +966,7 @@ func runInitFromAzureYaml( ) } if len(deploymentEntries) == 0 { - serviceName, err := foundryProjectServiceForModelOverride(content) + serviceName, err := foundryProjectServiceForModelOverride(content, "--model-deployment") if err != nil { return err } @@ -973,7 +976,7 @@ func runInitFromAzureYaml( }} } } else if flags.model != "" && result != nil && result.Credential != nil { - serviceName, err := foundryProjectServiceForModelOverride(content) + serviceName, err := foundryProjectServiceForModelOverride(content, "--model") if err != nil { return err } 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 ab83b07947b..ad4b17ab24f 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 @@ -158,7 +158,7 @@ services: host: azure.ai.agent `) - serviceName, err := foundryProjectServiceForModelOverride(content) + serviceName, err := foundryProjectServiceForModelOverride(content, "--model") require.Empty(t, serviceName) require.Error(t, err) require.Contains(t, err.Error(), "--model is ambiguous") @@ -174,7 +174,7 @@ services: host: azure.ai.agent `) - serviceName, err := foundryProjectServiceForModelOverride(content) + serviceName, err := foundryProjectServiceForModelOverride(content, "--model") require.NoError(t, err) require.Equal(t, "ai-project", serviceName) } From 81e4251e445fad442a2d149ba7a0119ec5332cfe Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:18:49 +0800 Subject: [PATCH 11/34] Validate Azure context flags before persisting --- .../azure.ai.agents/internal/cmd/init.go | 4 +--- .../internal/cmd/init_adopt.go | 4 +--- .../cmd/init_foundry_resources_helpers.go | 20 ++----------------- .../internal/cmd/init_from_code.go | 4 +--- 4 files changed, 5 insertions(+), 27 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 cb924d83eb8..89cdd5ca261 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -964,9 +964,7 @@ func runInitFromManifest( if err != nil { return err } - if err := applyAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { - return err - } + applyAzureContextFlags(azureContext, flags) // Create credential with whatever tenant is available (may be empty → default tenant) credential, err := azidentity.NewAzureDeveloperCLICredential( 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 289cc2becc3..52063ef4442 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 @@ -911,9 +911,7 @@ func runInitFromAzureYaml( if err != nil { return err } - if err := applyAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { - return err - } + applyAzureContextFlags(azureContext, flags) // Apply deploy-mode configuration to the adopted agent // service(s) before configuring the Foundry project. Whether an diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 3c9991a3379..dcb46ba683c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -1076,32 +1076,16 @@ func ensureLocation( return setEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION", azureContext.Scope.Location) } -func applyAzureContextFlags( - ctx context.Context, - azdClient *azdext.AzdClient, - azureContext *azdext.AzureContext, - envName string, - flags *initFlags, -) error { +func applyAzureContextFlags(azureContext *azdext.AzureContext, flags *initFlags) { if flags == nil { - return nil + return } if flags.subscriptionId != "" { azureContext.Scope.SubscriptionId = flags.subscriptionId - if err := setEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID", flags.subscriptionId); err != nil { - return err - } } if flags.location != "" { azureContext.Scope.Location = flags.location - if err := setEnvValue(ctx, azdClient, envName, "AZURE_LOCATION", flags.location); err != nil { - return err - } - if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION", flags.location); err != nil { - return err - } } - return nil } // ensureSubscriptionAndLocation ensures both subscription and location are set. 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 20c2585870a..399a1744c4a 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 @@ -279,9 +279,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) } a.azureContext = azureContext } - if err := applyAzureContextFlags(ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags); err != nil { - return nil, err - } + applyAzureContextFlags(a.azureContext, a.flags) // TODO: Prompt user for agent kind agentKind := agent_yaml.AgentKindHosted From 79fdc60c8de539dd12606cb6652536cff93601fe Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:08:27 +0800 Subject: [PATCH 12/34] Harden init context flag handling --- .../azure.ai.agents/internal/cmd/init.go | 5 ++ .../cmd/init_foundry_resources_helpers.go | 51 +++++++++++++++++++ .../internal/cmd/init_from_code.go | 5 ++ .../internal/cmd/init_locations_test.go | 4 +- 4 files changed, 63 insertions(+), 2 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 89cdd5ca261..20768edef86 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2024,6 +2024,11 @@ func (a *InitAction) configureModelChoice( hasModelResources := manifestHasModelResources(agentManifest) if a.flags.projectResourceId == "" && shouldDeferInitAzureContext(a.flags.noPrompt, a.azureContext) { + if err := persistValidatedAzureContextFlags( + ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags, + ); err != nil { + return nil, err + } // In headless init, missing Azure values should not block local scaffold generation. // Defer project/model setup and print the values required before provisioning. if err := configureDeferredInitAzureContext( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index dcb46ba683c..e4662420b31 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -1088,6 +1088,57 @@ func applyAzureContextFlags(azureContext *azdext.AzureContext, flags *initFlags) } } +func persistValidatedAzureContextFlags( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + envName string, + flags *initFlags, +) error { + if flags == nil { + return nil + } + if flags.subscriptionId != "" { + tenantResponse, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: flags.subscriptionId, + }) + if err != nil { + return exterrors.Auth( + exterrors.CodeTenantLookupFailed, + fmt.Sprintf("failed to lookup tenant for subscription %s: %s", flags.subscriptionId, err), + "verify your Azure login with 'azd auth login'", + ) + } + azureContext.Scope.TenantId = tenantResponse.TenantId + if err := setEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID", flags.subscriptionId); err != nil { + return err + } + if err := setEnvValue(ctx, azdClient, envName, "AZURE_TENANT_ID", tenantResponse.TenantId); err != nil { + return err + } + } + if flags.location != "" { + allowedLocations, err := supportedRegionsForInit(ctx) + if err != nil { + return err + } + if !locationAllowed(flags.location, allowedLocations) { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("location %q is not supported for this agent setup", flags.location), + "choose a supported hosted-agent region", + ) + } + if err := setEnvValue(ctx, azdClient, envName, "AZURE_LOCATION", flags.location); err != nil { + return err + } + if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION", flags.location); err != nil { + return err + } + } + return nil +} + // ensureSubscriptionAndLocation ensures both subscription and location are set. // Returns the (possibly refreshed) credential. func ensureSubscriptionAndLocation( 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 399a1744c4a..2d16ad5de52 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 @@ -354,6 +354,11 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } } else if shouldDeferInitAzureContext(a.flags.noPrompt, a.azureContext) { + if err := persistValidatedAzureContextFlags( + ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags, + ); err != nil { + return nil, err + } // In headless init, missing Azure values should not block local scaffold generation. // Defer project/model setup and print the values required before provisioning. if err := configureDeferredInitAzureContext( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index 82ddbb16dfb..8562a1d3bc5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -165,7 +165,7 @@ func TestSupportedRegionsForInit_FetchesOnceAndCaches(t *testing.T) { for range 3 { got, err := supportedRegionsForInit(t.Context()) require.NoError(t, err) - require.Contains(t, got, "eastus2") + require.Equal(t, []string{"eastus2"}, got) } require.Equal(t, 1, hits) } @@ -208,7 +208,7 @@ func TestSupportedRegionsForInit_ConcurrentCallersFetchOnce(t *testing.T) { defer wg.Done() got, err := supportedRegionsForInit(t.Context()) require.NoError(t, err) - require.Contains(t, got, "eastus2") + require.Equal(t, []string{"eastus2"}, got) }() } wg.Wait() From a39bbb124330d019edf99dbcf60ab966ed3197d9 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:20:22 +0800 Subject: [PATCH 13/34] Address adoption review comments --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 2 +- .../extensions/azure.ai.agents/internal/cmd/init_adopt.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 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 20768edef86..06931c1f463 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1521,7 +1521,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Path or URI to an agent manifest, or to a sample's unified azure.yaml to adopt as the project manifest") cmd.Flags().StringVar(&flags.agentName, "agent-name", "", - "Foundry agent name to write to agent.yaml. Reusing a name creates a new version of the existing agent.") + "Foundry agent name to write to agent.yaml or a single adopted azure.yaml agent service. Reusing a name creates a new version of the existing agent.") cmd.Flags().StringVarP(&flags.src, "src", "s", "", "Directory to download the agent definition to (defaults to 'src/')") 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 52063ef4442..d9deb1c8660 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 @@ -160,7 +160,7 @@ func foundryProjectServiceForModelOverride(content []byte, flagName string) (str return "", exterrors.Validation( exterrors.CodeInvalidAgentManifest, "sample azure.yaml does not declare an azure.ai.project service for model deployment", - "add an azure.ai.project service or omit --model", + fmt.Sprintf("add an azure.ai.project service or omit %s", flagName), ) case 1: return serviceNames[0], nil @@ -912,6 +912,11 @@ func runInitFromAzureYaml( return err } applyAzureContextFlags(azureContext, flags) + if shouldDeferInitAzureContext(flags.noPrompt, azureContext) { + if err := persistValidatedAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { + return err + } + } // Apply deploy-mode configuration to the adopted agent // service(s) before configuring the Foundry project. Whether an From b98706c7112a546bca13db9f8ed2b1e2a0ea9096 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:34:12 +0800 Subject: [PATCH 14/34] Apply protocol overrides to adopted samples --- .../azure.ai.agents/internal/cmd/init.go | 17 ++++-- .../internal/cmd/init_adopt.go | 52 +++++++++++++++++++ .../internal/cmd/init_adopt_test.go | 16 ++++++ 3 files changed, 82 insertions(+), 3 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 06931c1f463..c3f892194ba 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -663,14 +663,25 @@ func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.Proto if len(flagProtocols) == 0 { return []agent_yaml.ProtocolVersionRecord{{Protocol: "responses", Version: "2.0.0"}}, nil } + protocols, err := resolveKnownProtocols(flagProtocols) + if err != nil { + return nil, err + } + records := make([]agent_yaml.ProtocolVersionRecord, 0, len(protocols)) + for _, p := range protocols { + records = append(records, agent_yaml.ProtocolVersionRecord{Protocol: p.Name, Version: p.Version}) + } + return records, nil +} +func resolveKnownProtocols(flagProtocols []string) ([]protocolInfo, error) { versionOf := make(map[string]string, len(knownProtocols)) for _, p := range knownProtocols { versionOf[p.Name] = p.Version } seen := make(map[string]bool, len(flagProtocols)) - records := make([]agent_yaml.ProtocolVersionRecord, 0, len(flagProtocols)) + protocols := make([]protocolInfo, 0, len(flagProtocols)) for _, name := range flagProtocols { if seen[name] { continue @@ -685,10 +696,10 @@ func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.Proto "choose one of the supported protocols or omit --protocol to use the default", ) } - records = append(records, agent_yaml.ProtocolVersionRecord{Protocol: name, Version: version}) + protocols = append(protocols, protocolInfo{Name: name, Version: version}) } - return records, nil + return protocols, nil } // synthesizeImageManifestFile writes a minimal hosted container agent manifest to a 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 d9deb1c8660..fb5d5b7361a 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 @@ -204,6 +204,30 @@ func updateAzureYamlAgentName(ctx context.Context, azdClient *azdext.AzdClient, return nil } +func updateAzureYamlAgentProtocols( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + protocols []protocolInfo, +) error { + protocolDocs := make([]any, 0, len(protocols)) + for _, p := range protocols { + protocolDocs = append(protocolDocs, map[string]any{"protocol": p.Name, "version": p.Version}) + } + val, err := structpb.NewValue(protocolDocs) + if err != nil { + return fmt.Errorf("encoding protocols for service %q: %w", serviceName, err) + } + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "protocols", + Value: val, + }); err != nil { + return fmt.Errorf("updating protocols in azure.yaml for service %q: %w", serviceName, err) + } + return nil +} + func agentNameOverrideServices(content []byte, agentName string) ([]string, error) { if agentName == "" { return nil, nil @@ -223,6 +247,19 @@ func agentNameOverrideServices(content []byte, agentName string) ([]string, erro return agentServices, nil } +func agentOverrideServices(content []byte, flagName string) ([]string, error) { + agentServices := agentServiceNames(content) + if len(agentServices) > 1 { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("%s is ambiguous: sample declares %d agent services (%s)", + flagName, len(agentServices), strings.Join(agentServices, ", ")), + fmt.Sprintf("remove %s, or edit azure.yaml to update each agent individually", flagName), + ) + } + return agentServices, nil +} + func resolveDeploymentForModelFlag( ctx context.Context, azdClient *azdext.AzdClient, @@ -893,6 +930,21 @@ func runInitFromAzureYaml( } } } + if len(flags.protocols) > 0 { + agentServices, err := agentOverrideServices(content, "--protocol") + if err != nil { + return err + } + protocols, err := resolveKnownProtocols(flags.protocols) + if err != nil { + return err + } + for _, agentServiceName := range agentServices { + if err := updateAzureYamlAgentProtocols(ctx, azdClient, agentServiceName, protocols); 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 ad4b17ab24f..ca204bd4098 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 @@ -147,6 +147,22 @@ services: require.Equal(t, []string{"first"}, services) } +func TestAgentOverrideServices_MultipleAgentsReturnsAmbiguousError(t *testing.T) { + content := []byte(`name: multi-agent +services: + first: + host: azure.ai.agent + second: + host: azure.ai.agent +`) + + services, err := agentOverrideServices(content, "--protocol") + require.Nil(t, services) + require.Error(t, err) + require.Contains(t, err.Error(), "--protocol is ambiguous") + require.Contains(t, err.Error(), "first, second") +} + func TestFoundryProjectServiceForModelOverride_MultipleProjectsReturnsAmbiguousError(t *testing.T) { content := []byte(`name: multi-project services: From 98b49d17f9e972a7170b413397ef2f513f77245f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:56:10 +0800 Subject: [PATCH 15/34] Refine adoption override validation --- .../internal/cmd/init_adopt.go | 22 ++++++++++++++++++- .../cmd/init_foundry_resources_helpers.go | 21 +++++++++++++++++- 2 files changed, 41 insertions(+), 2 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 fb5d5b7361a..fbc880908a4 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 @@ -897,6 +897,26 @@ func runInitFromAzureYaml( "'azd ai agent init -m '", ) } + if _, err := agentNameOverrideServices(content, flags.agentName); err != nil { + return err + } + if len(flags.protocols) > 0 { + if _, err := agentOverrideServices(content, "--protocol"); err != nil { + return err + } + if _, err := resolveKnownProtocols(flags.protocols); err != nil { + return err + } + } + if flags.modelDeployment != "" { + if _, err := foundryProjectServiceForModelOverride(content, "--model-deployment"); err != nil { + return err + } + } else if flags.model != "" { + if _, err := foundryProjectServiceForModelOverride(content, "--model"); 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`. @@ -1037,7 +1057,7 @@ func runInitFromAzureYaml( } deployment, err := resolveDeploymentForModelFlag(ctx, azdClient, azureContext, flags.model) if err != nil { - return fmt.Errorf("failed to resolve model deployment for %q: %w", flags.model, err) + return err } if deployment != nil { if err := updateAzureYamlDeployments(ctx, azdClient, serviceName, []project.Deployment{*deployment}); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index e4662420b31..8e9dba07c70 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -1276,6 +1276,7 @@ func resolveModelDeployments( ModelName: model.Name, Options: &azdext.AiModelDeploymentOptions{ Locations: []string{location}, + Capacity: new(defaultDeploymentCapacity), }, Quota: &azdext.QuotaCheckOptions{ MinRemainingCapacity: 1, @@ -1285,7 +1286,25 @@ func resolveModelDeployments( return nil, err } - return resolveResp.Deployments, nil + if len(resolveResp.Deployments) > 0 { + return resolveResp.Deployments, nil + } + + fallbackResp, err := azdClient.Ai().ResolveModelDeployments(ctx, &azdext.ResolveModelDeploymentsRequest{ + AzureContext: azureContext, + ModelName: model.Name, + Options: &azdext.AiModelDeploymentOptions{ + Locations: []string{location}, + }, + Quota: &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + }, + }) + if err != nil { + return nil, err + } + + return fallbackResp.Deployments, nil } func selectBestModelDeploymentCandidate( From e12a9c5269408b6b825c5e37a0ed5aaa86820ec8 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:12:02 +0800 Subject: [PATCH 16/34] Address remaining adoption review feedback --- .../extensions/azure.ai.agents/internal/cmd/init.go | 3 ++- .../azure.ai.agents/internal/cmd/init_adopt.go | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 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 c3f892194ba..a91b97adbcc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1532,7 +1532,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Path or URI to an agent manifest, or to a sample's unified azure.yaml to adopt as the project manifest") cmd.Flags().StringVar(&flags.agentName, "agent-name", "", - "Foundry agent name to write to agent.yaml or a single adopted azure.yaml agent service. Reusing a name creates a new version of the existing agent.") + "Foundry agent name to write to agent.yaml or a single adopted azure.yaml agent service. "+ + "Reusing a name creates a new version of the existing agent.") cmd.Flags().StringVarP(&flags.src, "src", "s", "", "Directory to download the agent definition to (defaults to 'src/')") 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 fbc880908a4..b0d105a0c0c 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 @@ -269,7 +269,9 @@ func resolveDeploymentForModelFlag( if modelName == "" { return nil, nil } - deployment, err := resolveModelDeployment(ctx, azdClient, azureContext, &azdext.AiModel{Name: modelName}, azureContext.Scope.Location) + deployment, err := resolveModelDeployment( + ctx, azdClient, azureContext, &azdext.AiModel{Name: modelName}, azureContext.Scope.Location, + ) if err != nil { return nil, err } @@ -1050,7 +1052,14 @@ func runInitFromAzureYaml( Deployment: project.Deployment{Name: flags.modelDeployment}, }} } - } else if flags.model != "" && result != nil && result.Credential != nil { + } else if flags.model != "" { + if result == nil || result.Credential == nil { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--model requires Azure subscription and location values during sample adoption", + "pass both --subscription and --location, or run interactively to choose them", + ) + } serviceName, err := foundryProjectServiceForModelOverride(content, "--model") if err != nil { return err From e6bdd12259eb98a0988b35db6c9a939f801b3a2a Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:57:15 +0800 Subject: [PATCH 17/34] Support config-nested adopted agent overrides --- .../internal/cmd/init_adopt.go | 42 ++++++++++++++++--- .../internal/cmd/init_adopt_test.go | 25 +++++++++++ 2 files changed, 62 insertions(+), 5 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 b0d105a0c0c..b4b24b0dd20 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 @@ -189,14 +189,43 @@ func agentServiceNames(content []byte) []string { return names } -func updateAzureYamlAgentName(ctx context.Context, azdClient *azdext.AzdClient, serviceName, agentName string) error { +func agentServiceConfigValuePath(content []byte, serviceName string, field string) string { + var doc map[string]any + if err := yaml.Unmarshal(content, &doc); err != nil { + return field + } + services, ok := doc["services"].(map[string]any) + if !ok { + return field + } + service, ok := services[serviceName].(map[string]any) + if !ok { + return field + } + config, ok := service["config"].(map[string]any) + if !ok { + return field + } + if _, ok := config["kind"]; !ok { + return field + } + return "config." + field +} + +func updateAzureYamlAgentName( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + path string, + agentName string, +) error { val, err := structpb.NewValue(agentName) if err != nil { return fmt.Errorf("encoding agent name for service %q: %w", serviceName, err) } if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ ServiceName: serviceName, - Path: "name", + Path: path, Value: val, }); err != nil { return fmt.Errorf("updating agent name in azure.yaml for service %q: %w", serviceName, err) @@ -208,6 +237,7 @@ func updateAzureYamlAgentProtocols( ctx context.Context, azdClient *azdext.AzdClient, serviceName string, + path string, protocols []protocolInfo, ) error { protocolDocs := make([]any, 0, len(protocols)) @@ -220,7 +250,7 @@ func updateAzureYamlAgentProtocols( } if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ ServiceName: serviceName, - Path: "protocols", + Path: path, Value: val, }); err != nil { return fmt.Errorf("updating protocols in azure.yaml for service %q: %w", serviceName, err) @@ -947,7 +977,8 @@ func runInitFromAzureYaml( return err } for _, agentServiceName := range agentServices { - if err := updateAzureYamlAgentName(ctx, azdClient, agentServiceName, flags.agentName); err != nil { + path := agentServiceConfigValuePath(content, agentServiceName, "name") + if err := updateAzureYamlAgentName(ctx, azdClient, agentServiceName, path, flags.agentName); err != nil { return err } } @@ -962,7 +993,8 @@ func runInitFromAzureYaml( return err } for _, agentServiceName := range agentServices { - if err := updateAzureYamlAgentProtocols(ctx, azdClient, agentServiceName, protocols); err != nil { + path := agentServiceConfigValuePath(content, agentServiceName, "protocols") + if err := updateAzureYamlAgentProtocols(ctx, azdClient, agentServiceName, path, protocols); err != nil { return err } } 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 ca204bd4098..27bc79a9a00 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 @@ -163,6 +163,31 @@ services: require.Contains(t, err.Error(), "first, second") } +func TestAgentServiceConfigValuePath_ConfigNestedKindUsesConfigPath(t *testing.T) { + content := []byte(`name: config-nested +services: + agent: + host: azure.ai.agent + config: + kind: hosted + name: agent-name +`) + + require.Equal(t, "config.name", agentServiceConfigValuePath(content, "agent", "name")) + require.Equal(t, "config.protocols", agentServiceConfigValuePath(content, "agent", "protocols")) +} + +func TestAgentServiceConfigValuePath_RootAgentUsesRootPath(t *testing.T) { + content := []byte(`name: root-agent +services: + agent: + host: azure.ai.agent + name: agent-name +`) + + require.Equal(t, "name", agentServiceConfigValuePath(content, "agent", "name")) +} + func TestFoundryProjectServiceForModelOverride_MultipleProjectsReturnsAmbiguousError(t *testing.T) { content := []byte(`name: multi-project services: From 04f83c0dbc84109780b49c6b7af1b6f32bab440c Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:11:43 +0800 Subject: [PATCH 18/34] Validate adopted agent override targets --- .../internal/cmd/init_adopt.go | 27 +++++++-- .../internal/cmd/init_adopt_test.go | 55 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 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 b4b24b0dd20..b0d4108905c 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 @@ -202,11 +202,14 @@ func agentServiceConfigValuePath(content []byte, serviceName string, field strin if !ok { return field } + if kind, ok := service["kind"].(string); ok && strings.TrimSpace(kind) != "" { + return field + } config, ok := service["config"].(map[string]any) if !ok { return field } - if _, ok := config["kind"]; !ok { + if kind, ok := config["kind"].(string); !ok || strings.TrimSpace(kind) == "" { return field } return "config." + field @@ -266,6 +269,13 @@ func agentNameOverrideServices(content []byte, agentName string) ([]string, erro return nil, err } agentServices := agentServiceNames(content) + if len(agentServices) == 0 { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-name requires a sample with an azure.ai.agent service", + "remove --agent-name, or use a sample that declares an agent service", + ) + } if len(agentServices) > 1 { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, @@ -279,6 +289,13 @@ func agentNameOverrideServices(content []byte, agentName string) ([]string, erro func agentOverrideServices(content []byte, flagName string) ([]string, error) { agentServices := agentServiceNames(content) + if len(agentServices) == 0 { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("%s requires a sample with an azure.ai.agent service", flagName), + fmt.Sprintf("remove %s, or use a sample that declares an agent service", flagName), + ) + } if len(agentServices) > 1 { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, @@ -299,9 +316,11 @@ func resolveDeploymentForModelFlag( if modelName == "" { return nil, nil } - deployment, err := resolveModelDeployment( - ctx, azdClient, azureContext, &azdext.AiModel{Name: modelName}, azureContext.Scope.Location, - ) + model, err := selectNewModel(ctx, azdClient, azureContext, modelName) + if err != nil { + return nil, err + } + deployment, err := resolveModelDeployment(ctx, azdClient, azureContext, model, azureContext.Scope.Location) if err != nil { return nil, err } 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 27bc79a9a00..f0a4fe11690 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 @@ -134,6 +134,19 @@ services: require.Contains(t, err.Error(), "first, second") } +func TestAgentNameOverrideServices_NoAgentReturnsError(t *testing.T) { + content := []byte(`name: no-agent +services: + ai-project: + host: azure.ai.project +`) + + services, err := agentNameOverrideServices(content, "shared-name") + require.Nil(t, services) + require.Error(t, err) + require.Contains(t, err.Error(), "--agent-name requires") +} + func TestAgentNameOverrideServices_SingleAgentReturnsServiceName(t *testing.T) { content := []byte(`name: one-agent services: @@ -163,6 +176,19 @@ services: require.Contains(t, err.Error(), "first, second") } +func TestAgentOverrideServices_NoAgentReturnsError(t *testing.T) { + content := []byte(`name: no-agent +services: + ai-project: + host: azure.ai.project +`) + + services, err := agentOverrideServices(content, "--protocol") + require.Nil(t, services) + require.Error(t, err) + require.Contains(t, err.Error(), "--protocol requires") +} + func TestAgentServiceConfigValuePath_ConfigNestedKindUsesConfigPath(t *testing.T) { content := []byte(`name: config-nested services: @@ -177,6 +203,35 @@ services: require.Equal(t, "config.protocols", agentServiceConfigValuePath(content, "agent", "protocols")) } +func TestAgentServiceConfigValuePath_InlineKindWinsOverConfigKind(t *testing.T) { + content := []byte(`name: dual-shape +services: + agent: + host: azure.ai.agent + kind: hosted + name: inline-agent + config: + kind: hosted + name: config-agent +`) + + require.Equal(t, "name", agentServiceConfigValuePath(content, "agent", "name")) + require.Equal(t, "protocols", agentServiceConfigValuePath(content, "agent", "protocols")) +} + +func TestAgentServiceConfigValuePath_EmptyConfigKindUsesRootPath(t *testing.T) { + content := []byte(`name: empty-config-kind +services: + agent: + host: azure.ai.agent + config: + kind: "" + name: config-agent +`) + + require.Equal(t, "name", agentServiceConfigValuePath(content, "agent", "name")) +} + func TestAgentServiceConfigValuePath_RootAgentUsesRootPath(t *testing.T) { content := []byte(`name: root-agent services: From 7a5b32084052bdf6c0c553eab1aacd41575dbc1f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:22:37 +0800 Subject: [PATCH 19/34] Validate no-prompt Azure context flags --- .../extensions/azure.ai.agents/internal/cmd/init.go | 10 +++++----- .../azure.ai.agents/internal/cmd/init_adopt.go | 2 +- .../azure.ai.agents/internal/cmd/init_from_code.go | 10 +++++----- 3 files changed, 11 insertions(+), 11 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 a91b97adbcc..334529517d2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -976,6 +976,11 @@ func runInitFromManifest( return err } applyAzureContextFlags(azureContext, flags) + if flags.noPrompt { + if err := persistValidatedAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { + return err + } + } // Create credential with whatever tenant is available (may be empty → default tenant) credential, err := azidentity.NewAzureDeveloperCLICredential( @@ -2036,11 +2041,6 @@ func (a *InitAction) configureModelChoice( hasModelResources := manifestHasModelResources(agentManifest) if a.flags.projectResourceId == "" && shouldDeferInitAzureContext(a.flags.noPrompt, a.azureContext) { - if err := persistValidatedAzureContextFlags( - ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags, - ); err != nil { - return nil, err - } // In headless init, missing Azure values should not block local scaffold generation. // Defer project/model setup and print the values required before provisioning. if err := configureDeferredInitAzureContext( 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 b0d4108905c..f2562da54ee 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 @@ -1037,7 +1037,7 @@ func runInitFromAzureYaml( return err } applyAzureContextFlags(azureContext, flags) - if shouldDeferInitAzureContext(flags.noPrompt, azureContext) { + if flags.noPrompt { if err := persistValidatedAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { return err } 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 2d16ad5de52..6667828dc29 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 @@ -280,6 +280,11 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) a.azureContext = azureContext } applyAzureContextFlags(a.azureContext, a.flags) + if a.flags.noPrompt { + if err := persistValidatedAzureContextFlags(ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags); err != nil { + return nil, err + } + } // TODO: Prompt user for agent kind agentKind := agent_yaml.AgentKindHosted @@ -354,11 +359,6 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } } else if shouldDeferInitAzureContext(a.flags.noPrompt, a.azureContext) { - if err := persistValidatedAzureContextFlags( - ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags, - ); err != nil { - return nil, err - } // In headless init, missing Azure values should not block local scaffold generation. // Defer project/model setup and print the values required before provisioning. if err := configureDeferredInitAzureContext( From 241952d780255240d70e1622e5d67df50db14925 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:51:01 +0800 Subject: [PATCH 20/34] Refine image protocol and model resolution --- .../azure.ai.agents/internal/cmd/init.go | 7 +++++++ .../internal/cmd/init_foundry_resources_helpers.go | 8 ++++---- .../azure.ai.agents/internal/cmd/init_test.go | 14 ++++++++++++++ 3 files changed, 25 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 334529517d2..1f0f9024c21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -687,6 +687,13 @@ func resolveKnownProtocols(flagProtocols []string) ([]protocolInfo, error) { continue } seen[name] = true + if name == "activity" { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "--protocol activity is not supported with --image", + "use code init for activity agents, or choose a different protocol for --image", + ) + } version, ok := versionOf[name] if !ok { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 8e9dba07c70..32f86bae668 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -1283,10 +1283,10 @@ func resolveModelDeployments( }, }) if err != nil { - return nil, err - } - - if len(resolveResp.Deployments) > 0 { + if !hasAiErrorReason(err, azdext.AiErrorReasonNoDeploymentMatch) { + return nil, err + } + } else if len(resolveResp.Deployments) > 0 { return resolveResp.Deployments, nil } 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 e839ee04c1e..baa94021e41 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 @@ -379,6 +379,20 @@ func TestSynthesizeImageManifestFile_RejectsUnknownProtocol(t *testing.T) { require.Contains(t, err.Error(), "unknown protocol") } +func TestSynthesizeImageManifestFile_RejectsActivityProtocol(t *testing.T) { + t.Parallel() + + manifestPath, cleanup, err := synthesizeImageManifestFile( + "my-agent", + "myacr.azurecr.io/agents/my-agent:v1", + []string{"activity"}, + ) + require.Error(t, err) + require.Empty(t, manifestPath) + cleanup() + require.Contains(t, err.Error(), "--protocol activity is not supported with --image") +} + func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { const image = "myacr.azurecr.io/agents/my-agent:v1" server := &recordingProjectServer{} From 9c9ad64c0eb27560e599013b62e266026af34d0d Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:27:58 +0800 Subject: [PATCH 21/34] Narrow invocations_ws init PR scope --- .../azure.ai.agents/internal/cmd/init.go | 32 +- .../internal/cmd/init_adopt.go | 297 ------------------ .../internal/cmd/init_adopt_test.go | 159 ---------- .../cmd/init_foundry_resources_helpers.go | 83 +---- .../internal/cmd/init_from_code.go | 7 - 5 files changed, 11 insertions(+), 567 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 1f0f9024c21..61b12311e57 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -51,8 +51,6 @@ import ( type initFlags struct { projectResourceId string - subscriptionId string - location string modelDeployment string model string manifestPointer string @@ -663,6 +661,15 @@ func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.Proto if len(flagProtocols) == 0 { return []agent_yaml.ProtocolVersionRecord{{Protocol: "responses", Version: "2.0.0"}}, nil } + for _, protocol := range flagProtocols { + if protocol == "activity" { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "--protocol activity is not supported with --image", + "use code init for activity agents, or choose a different protocol for --image", + ) + } + } protocols, err := resolveKnownProtocols(flagProtocols) if err != nil { return nil, err @@ -687,13 +694,6 @@ func resolveKnownProtocols(flagProtocols []string) ([]protocolInfo, error) { continue } seen[name] = true - if name == "activity" { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - "--protocol activity is not supported with --image", - "use code init for activity agents, or choose a different protocol for --image", - ) - } version, ok := versionOf[name] if !ok { @@ -982,13 +982,6 @@ func runInitFromManifest( if err != nil { return err } - applyAzureContextFlags(azureContext, flags) - if flags.noPrompt { - if err := persistValidatedAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { - return err - } - } - // Create credential with whatever tenant is available (may be empty → default tenant) credential, err := azidentity.NewAzureDeveloperCLICredential( &azidentity.AzureDeveloperCLICredentialOptions{ @@ -1529,10 +1522,6 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, cmd.Flags().StringVarP(&flags.projectResourceId, "project-id", "p", "", "Existing Microsoft Foundry Project Id to initialize your azd environment with") - cmd.Flags().StringVar(&flags.subscriptionId, "subscription", "", - "Azure subscription ID to use for initializing and provisioning the Foundry project") - cmd.Flags().StringVarP(&flags.location, "location", "l", "", - "Azure location to use for initializing and provisioning the Foundry project") cmd.Flags().StringVarP(&flags.modelDeployment, "model-deployment", "d", "", "Name of an existing model deployment to use from the Foundry project. Only used when paired with an existing Foundry project, either via --project-id or interactive prompts") @@ -1544,8 +1533,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Path or URI to an agent manifest, or to a sample's unified azure.yaml to adopt as the project manifest") cmd.Flags().StringVar(&flags.agentName, "agent-name", "", - "Foundry agent name to write to agent.yaml or a single adopted azure.yaml agent service. "+ - "Reusing a name creates a new version of the existing agent.") + "Foundry agent name to write to agent.yaml. Reusing a name creates a new version of the existing agent.") cmd.Flags().StringVarP(&flags.src, "src", "s", "", "Directory to download the agent definition to (defaults to 'src/')") 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 f2562da54ee..13b1d6b5c31 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 @@ -136,208 +136,6 @@ func foundryDeployments(content []byte) []foundryDeploymentEntry { return entries } -func foundryProjectServiceForModelOverride(content []byte, flagName string) (string, error) { - if flagName == "" { - flagName = "--model" - } - var doc azureYamlServices - if err := yaml.Unmarshal(content, &doc); err != nil { - return "", exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - "sample azure.yaml could not be parsed", - "fix the sample manifest and run init again", - ) - } - serviceNames := make([]string, 0, len(doc.Services)) - for svcName, svc := range doc.Services { - if svc.Host == "azure.ai.project" { - serviceNames = append(serviceNames, svcName) - } - } - slices.Sort(serviceNames) - switch len(serviceNames) { - case 0: - return "", exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - "sample azure.yaml does not declare an azure.ai.project service for model deployment", - fmt.Sprintf("add an azure.ai.project service or omit %s", flagName), - ) - case 1: - return serviceNames[0], nil - default: - return "", exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("%s is ambiguous: sample declares %d project services (%s)", - flagName, len(serviceNames), strings.Join(serviceNames, ", ")), - fmt.Sprintf("remove %s, or edit azure.yaml to add the deployment to the intended project service", flagName), - ) - } -} - -func agentServiceNames(content []byte) []string { - var doc azureYamlServices - if err := yaml.Unmarshal(content, &doc); err != nil { - return nil - } - var names []string - for svcName, svc := range doc.Services { - if svc.Host == "azure.ai.agent" { - names = append(names, svcName) - } - } - slices.Sort(names) - return names -} - -func agentServiceConfigValuePath(content []byte, serviceName string, field string) string { - var doc map[string]any - if err := yaml.Unmarshal(content, &doc); err != nil { - return field - } - services, ok := doc["services"].(map[string]any) - if !ok { - return field - } - service, ok := services[serviceName].(map[string]any) - if !ok { - return field - } - if kind, ok := service["kind"].(string); ok && strings.TrimSpace(kind) != "" { - return field - } - config, ok := service["config"].(map[string]any) - if !ok { - return field - } - if kind, ok := config["kind"].(string); !ok || strings.TrimSpace(kind) == "" { - return field - } - return "config." + field -} - -func updateAzureYamlAgentName( - ctx context.Context, - azdClient *azdext.AzdClient, - serviceName string, - path string, - agentName string, -) error { - val, err := structpb.NewValue(agentName) - if err != nil { - return fmt.Errorf("encoding agent name for service %q: %w", serviceName, err) - } - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ - ServiceName: serviceName, - Path: path, - Value: val, - }); err != nil { - return fmt.Errorf("updating agent name in azure.yaml for service %q: %w", serviceName, err) - } - return nil -} - -func updateAzureYamlAgentProtocols( - ctx context.Context, - azdClient *azdext.AzdClient, - serviceName string, - path string, - protocols []protocolInfo, -) error { - protocolDocs := make([]any, 0, len(protocols)) - for _, p := range protocols { - protocolDocs = append(protocolDocs, map[string]any{"protocol": p.Name, "version": p.Version}) - } - val, err := structpb.NewValue(protocolDocs) - if err != nil { - return fmt.Errorf("encoding protocols for service %q: %w", serviceName, err) - } - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ - ServiceName: serviceName, - Path: path, - Value: val, - }); err != nil { - return fmt.Errorf("updating protocols in azure.yaml for service %q: %w", serviceName, err) - } - return nil -} - -func agentNameOverrideServices(content []byte, agentName string) ([]string, error) { - if agentName == "" { - return nil, nil - } - if _, err := validateInitAgentName(agentName); err != nil { - return nil, err - } - agentServices := agentServiceNames(content) - if len(agentServices) == 0 { - return nil, exterrors.Validation( - exterrors.CodeInvalidParameter, - "--agent-name requires a sample with an azure.ai.agent service", - "remove --agent-name, or use a sample that declares an agent service", - ) - } - if len(agentServices) > 1 { - return nil, exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("--agent-name is ambiguous: sample declares %d agent services (%s)", - len(agentServices), strings.Join(agentServices, ", ")), - "remove --agent-name, or edit azure.yaml to rename each agent individually", - ) - } - return agentServices, nil -} - -func agentOverrideServices(content []byte, flagName string) ([]string, error) { - agentServices := agentServiceNames(content) - if len(agentServices) == 0 { - return nil, exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("%s requires a sample with an azure.ai.agent service", flagName), - fmt.Sprintf("remove %s, or use a sample that declares an agent service", flagName), - ) - } - if len(agentServices) > 1 { - return nil, exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("%s is ambiguous: sample declares %d agent services (%s)", - flagName, len(agentServices), strings.Join(agentServices, ", ")), - fmt.Sprintf("remove %s, or edit azure.yaml to update each agent individually", flagName), - ) - } - return agentServices, nil -} - -func resolveDeploymentForModelFlag( - ctx context.Context, - azdClient *azdext.AzdClient, - azureContext *azdext.AzureContext, - modelName string, -) (*project.Deployment, error) { - if modelName == "" { - return nil, nil - } - model, err := selectNewModel(ctx, azdClient, azureContext, modelName) - if err != nil { - return nil, err - } - deployment, err := resolveModelDeployment(ctx, azdClient, azureContext, model, azureContext.Scope.Location) - if err != nil { - return nil, err - } - return &project.Deployment{ - Name: deployment.ModelName, - Model: project.DeploymentModel{ - Name: deployment.ModelName, - Format: deployment.Format, - Version: deployment.Version, - }, - Sku: project.DeploymentSku{ - Name: deployment.Sku.Name, - Capacity: int(deployment.Capacity), - }, - }, nil -} - // verifyAzureYamlDeployments checks each model deployment declared in the // unified azure.yaml against the selected Foundry project's existing // deployments. It prompts the user for each deployment and returns the filtered @@ -948,26 +746,6 @@ func runInitFromAzureYaml( "'azd ai agent init -m '", ) } - if _, err := agentNameOverrideServices(content, flags.agentName); err != nil { - return err - } - if len(flags.protocols) > 0 { - if _, err := agentOverrideServices(content, "--protocol"); err != nil { - return err - } - if _, err := resolveKnownProtocols(flags.protocols); err != nil { - return err - } - } - if flags.modelDeployment != "" { - if _, err := foundryProjectServiceForModelOverride(content, "--model-deployment"); err != nil { - return err - } - } else if flags.model != "" { - if _, err := foundryProjectServiceForModelOverride(content, "--model"); 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`. @@ -990,34 +768,6 @@ func runInitFromAzureYaml( if err := ensureFoundryProviderDeclared(ctx, azdClient); err != nil { return err } - if flags.agentName != "" { - agentServices, err := agentNameOverrideServices(content, flags.agentName) - if err != nil { - return err - } - for _, agentServiceName := range agentServices { - path := agentServiceConfigValuePath(content, agentServiceName, "name") - if err := updateAzureYamlAgentName(ctx, azdClient, agentServiceName, path, flags.agentName); err != nil { - return err - } - } - } - if len(flags.protocols) > 0 { - agentServices, err := agentOverrideServices(content, "--protocol") - if err != nil { - return err - } - protocols, err := resolveKnownProtocols(flags.protocols) - if err != nil { - return err - } - for _, agentServiceName := range agentServices { - path := agentServiceConfigValuePath(content, agentServiceName, "protocols") - if err := updateAzureYamlAgentProtocols(ctx, azdClient, agentServiceName, path, protocols); err != nil { - return err - } - } - } // --- Interactive Azure context setup (subscription, Foundry project) --- // The scaffolding created an environment; load it and run the same Foundry @@ -1036,12 +786,6 @@ func runInitFromAzureYaml( if err != nil { return err } - applyAzureContextFlags(azureContext, flags) - if flags.noPrompt { - if err := persistValidatedAzureContextFlags(ctx, azdClient, azureContext, env.Name, flags); err != nil { - return err - } - } // Apply deploy-mode configuration to the adopted agent // service(s) before configuring the Foundry project. Whether an @@ -1085,47 +829,6 @@ func runInitFromAzureYaml( // selected Foundry project. If the user opts to use existing deployments // or skip, we update the on-disk azure.yaml accordingly. deploymentEntries := foundryDeployments(content) - if flags.modelDeployment != "" { - if result.FoundryProject == nil { - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--model-deployment requires an existing Foundry project", - "provide --project-id with --model-deployment, or use --model to provision a new deployment", - ) - } - if len(deploymentEntries) == 0 { - serviceName, err := foundryProjectServiceForModelOverride(content, "--model-deployment") - if err != nil { - return err - } - deploymentEntries = []foundryDeploymentEntry{{ - ServiceName: serviceName, - Deployment: project.Deployment{Name: flags.modelDeployment}, - }} - } - } else if flags.model != "" { - if result == nil || result.Credential == nil { - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--model requires Azure subscription and location values during sample adoption", - "pass both --subscription and --location, or run interactively to choose them", - ) - } - serviceName, err := foundryProjectServiceForModelOverride(content, "--model") - if err != nil { - return err - } - deployment, err := resolveDeploymentForModelFlag(ctx, azdClient, azureContext, flags.model) - if err != nil { - return err - } - if deployment != nil { - if err := updateAzureYamlDeployments(ctx, azdClient, serviceName, []project.Deployment{*deployment}); err != nil { - return err - } - deploymentEntries = []foundryDeploymentEntry{{ServiceName: serviceName, Deployment: *deployment}} - } - } if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { keptEntries, referencedDeployments, deploymentsModified, err := verifyAzureYamlDeployments( ctx, azdClient, result.Credential, azureContext, env.Name, 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 f0a4fe11690..f89270b6150 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 @@ -116,165 +116,6 @@ func TestFoundryProjectName(t *testing.T) { } } -func TestAgentNameOverrideServices_MultipleAgentsReturnsAmbiguousError(t *testing.T) { - content := []byte(`name: multi-agent -services: - first: - host: azure.ai.agent - name: first-agent - second: - host: azure.ai.agent - name: second-agent -`) - - services, err := agentNameOverrideServices(content, "shared-name") - require.Nil(t, services) - require.Error(t, err) - require.Contains(t, err.Error(), "--agent-name is ambiguous") - require.Contains(t, err.Error(), "first, second") -} - -func TestAgentNameOverrideServices_NoAgentReturnsError(t *testing.T) { - content := []byte(`name: no-agent -services: - ai-project: - host: azure.ai.project -`) - - services, err := agentNameOverrideServices(content, "shared-name") - require.Nil(t, services) - require.Error(t, err) - require.Contains(t, err.Error(), "--agent-name requires") -} - -func TestAgentNameOverrideServices_SingleAgentReturnsServiceName(t *testing.T) { - content := []byte(`name: one-agent -services: - first: - host: azure.ai.agent - name: first-agent -`) - - services, err := agentNameOverrideServices(content, "renamed") - require.NoError(t, err) - require.Equal(t, []string{"first"}, services) -} - -func TestAgentOverrideServices_MultipleAgentsReturnsAmbiguousError(t *testing.T) { - content := []byte(`name: multi-agent -services: - first: - host: azure.ai.agent - second: - host: azure.ai.agent -`) - - services, err := agentOverrideServices(content, "--protocol") - require.Nil(t, services) - require.Error(t, err) - require.Contains(t, err.Error(), "--protocol is ambiguous") - require.Contains(t, err.Error(), "first, second") -} - -func TestAgentOverrideServices_NoAgentReturnsError(t *testing.T) { - content := []byte(`name: no-agent -services: - ai-project: - host: azure.ai.project -`) - - services, err := agentOverrideServices(content, "--protocol") - require.Nil(t, services) - require.Error(t, err) - require.Contains(t, err.Error(), "--protocol requires") -} - -func TestAgentServiceConfigValuePath_ConfigNestedKindUsesConfigPath(t *testing.T) { - content := []byte(`name: config-nested -services: - agent: - host: azure.ai.agent - config: - kind: hosted - name: agent-name -`) - - require.Equal(t, "config.name", agentServiceConfigValuePath(content, "agent", "name")) - require.Equal(t, "config.protocols", agentServiceConfigValuePath(content, "agent", "protocols")) -} - -func TestAgentServiceConfigValuePath_InlineKindWinsOverConfigKind(t *testing.T) { - content := []byte(`name: dual-shape -services: - agent: - host: azure.ai.agent - kind: hosted - name: inline-agent - config: - kind: hosted - name: config-agent -`) - - require.Equal(t, "name", agentServiceConfigValuePath(content, "agent", "name")) - require.Equal(t, "protocols", agentServiceConfigValuePath(content, "agent", "protocols")) -} - -func TestAgentServiceConfigValuePath_EmptyConfigKindUsesRootPath(t *testing.T) { - content := []byte(`name: empty-config-kind -services: - agent: - host: azure.ai.agent - config: - kind: "" - name: config-agent -`) - - require.Equal(t, "name", agentServiceConfigValuePath(content, "agent", "name")) -} - -func TestAgentServiceConfigValuePath_RootAgentUsesRootPath(t *testing.T) { - content := []byte(`name: root-agent -services: - agent: - host: azure.ai.agent - name: agent-name -`) - - require.Equal(t, "name", agentServiceConfigValuePath(content, "agent", "name")) -} - -func TestFoundryProjectServiceForModelOverride_MultipleProjectsReturnsAmbiguousError(t *testing.T) { - content := []byte(`name: multi-project -services: - first-project: - host: azure.ai.project - second-project: - host: azure.ai.project - agent: - host: azure.ai.agent -`) - - serviceName, err := foundryProjectServiceForModelOverride(content, "--model") - require.Empty(t, serviceName) - require.Error(t, err) - require.Contains(t, err.Error(), "--model is ambiguous") - require.Contains(t, err.Error(), "first-project, second-project") -} - -func TestFoundryProjectServiceForModelOverride_SingleProjectReturnsServiceName(t *testing.T) { - content := []byte(`name: one-project -services: - ai-project: - host: azure.ai.project - agent: - host: azure.ai.agent -`) - - serviceName, err := foundryProjectServiceForModelOverride(content, "--model") - require.NoError(t, err) - require.Equal(t, "ai-project", serviceName) -} - func TestParentDirOf(t *testing.T) { tests := []struct { filePath string diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 32f86bae668..bbd9e458c64 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -1076,69 +1076,6 @@ func ensureLocation( return setEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION", azureContext.Scope.Location) } -func applyAzureContextFlags(azureContext *azdext.AzureContext, flags *initFlags) { - if flags == nil { - return - } - if flags.subscriptionId != "" { - azureContext.Scope.SubscriptionId = flags.subscriptionId - } - if flags.location != "" { - azureContext.Scope.Location = flags.location - } -} - -func persistValidatedAzureContextFlags( - ctx context.Context, - azdClient *azdext.AzdClient, - azureContext *azdext.AzureContext, - envName string, - flags *initFlags, -) error { - if flags == nil { - return nil - } - if flags.subscriptionId != "" { - tenantResponse, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: flags.subscriptionId, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeTenantLookupFailed, - fmt.Sprintf("failed to lookup tenant for subscription %s: %s", flags.subscriptionId, err), - "verify your Azure login with 'azd auth login'", - ) - } - azureContext.Scope.TenantId = tenantResponse.TenantId - if err := setEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID", flags.subscriptionId); err != nil { - return err - } - if err := setEnvValue(ctx, azdClient, envName, "AZURE_TENANT_ID", tenantResponse.TenantId); err != nil { - return err - } - } - if flags.location != "" { - allowedLocations, err := supportedRegionsForInit(ctx) - if err != nil { - return err - } - if !locationAllowed(flags.location, allowedLocations) { - return exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("location %q is not supported for this agent setup", flags.location), - "choose a supported hosted-agent region", - ) - } - if err := setEnvValue(ctx, azdClient, envName, "AZURE_LOCATION", flags.location); err != nil { - return err - } - if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION", flags.location); err != nil { - return err - } - } - return nil -} - // ensureSubscriptionAndLocation ensures both subscription and location are set. // Returns the (possibly refreshed) credential. func ensureSubscriptionAndLocation( @@ -1282,29 +1219,11 @@ func resolveModelDeployments( MinRemainingCapacity: 1, }, }) - if err != nil { - if !hasAiErrorReason(err, azdext.AiErrorReasonNoDeploymentMatch) { - return nil, err - } - } else if len(resolveResp.Deployments) > 0 { - return resolveResp.Deployments, nil - } - - fallbackResp, err := azdClient.Ai().ResolveModelDeployments(ctx, &azdext.ResolveModelDeploymentsRequest{ - AzureContext: azureContext, - ModelName: model.Name, - Options: &azdext.AiModelDeploymentOptions{ - Locations: []string{location}, - }, - Quota: &azdext.QuotaCheckOptions{ - MinRemainingCapacity: 1, - }, - }) if err != nil { return nil, err } - return fallbackResp.Deployments, nil + return resolveResp.Deployments, nil } func selectBestModelDeploymentCandidate( 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 6667828dc29..b5f31fc7f9c 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 @@ -279,13 +279,6 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) } a.azureContext = azureContext } - applyAzureContextFlags(a.azureContext, a.flags) - if a.flags.noPrompt { - if err := persistValidatedAzureContextFlags(ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags); err != nil { - return nil, err - } - } - // TODO: Prompt user for agent kind agentKind := agent_yaml.AgentKindHosted From a519b8723331a0793877ed5b6f8114b51973869f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:34:41 +0800 Subject: [PATCH 22/34] Apply go fix suggestions --- .../azure.ai.agents/internal/cmd/init.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 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 61b12311e57..d0846eb9476 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -20,6 +20,7 @@ import ( osExec "os/exec" "path/filepath" "regexp" + "slices" "strings" "time" @@ -661,14 +662,12 @@ func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.Proto if len(flagProtocols) == 0 { return []agent_yaml.ProtocolVersionRecord{{Protocol: "responses", Version: "2.0.0"}}, nil } - for _, protocol := range flagProtocols { - if protocol == "activity" { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - "--protocol activity is not supported with --image", - "use code init for activity agents, or choose a different protocol for --image", - ) - } + if slices.Contains(flagProtocols, "activity") { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "--protocol activity is not supported with --image", + "use code init for activity agents, or choose a different protocol for --image", + ) } protocols, err := resolveKnownProtocols(flagProtocols) if err != nil { From d5db27ab252198bd45d7baddafd70801d2d04ca2 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:55:08 +0800 Subject: [PATCH 23/34] Wrap long init protocol lines --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 3 ++- .../azure.ai.agents/internal/cmd/init_from_code_test.go | 6 +++++- 2 files changed, 7 insertions(+), 2 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 d0846eb9476..6894b63040a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1538,7 +1538,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Directory to download the agent definition to (defaults to 'src/')") cmd.Flags().StringSliceVar(&flags.protocols, "protocol", nil, - "Protocols supported by the agent (e.g., 'responses', 'invocations', 'invocations_ws'). Can be specified multiple times.") + "Protocols supported by the agent (e.g., 'responses', 'invocations', 'invocations_ws'). "+ + "Can be specified multiple times.") cmd.Flags().StringVar(&flags.deployMode, "deploy-mode", "", "Deployment mode: 'container' (Docker image) or 'code' (ZIP upload). Defaults to 'code' for Python/.NET projects in --no-prompt.") 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 a42e3b8a835..c7d3166c748 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 @@ -773,7 +773,11 @@ func TestPromptProtocols_Interactive(t *testing.T) { }, { name: "websocket protocol selected", - multiSelectFn: func(_ context.Context, _ *azdext.MultiSelectRequest, _ ...grpc.CallOption) (*azdext.MultiSelectResponse, error) { + multiSelectFn: func( + _ context.Context, + _ *azdext.MultiSelectRequest, + _ ...grpc.CallOption, + ) (*azdext.MultiSelectResponse, error) { return &azdext.MultiSelectResponse{ Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: false}, From 7f62eb0c0a92b8358484e4b267f146b9831678ca Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:51:24 +0800 Subject: [PATCH 24/34] Suppress local invoke hint for websocket agents --- .../azure.ai.agents/internal/cmd/nextstep/resolver.go | 6 ++++++ .../azure.ai.agents/internal/cmd/nextstep/state.go | 2 ++ .../azure.ai.agents/internal/cmd/nextstep/state_test.go | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index 4cb658aaea9..f33b09943c8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -17,6 +17,9 @@ const ( // ProtocolResponses is the value of `agent.yaml#protocol` for plain // text /responses agents. ProtocolResponses = "responses" + // ProtocolInvocationsWS is the value of `agent.yaml#protocol` for + // bidirectional WebSocket /invocations_ws agents. + ProtocolInvocationsWS = "invocations_ws" // placeholderPayload is the single-quoted literal the resolver // emits as the body argument when no concrete payload is known — @@ -770,6 +773,9 @@ func appendInvokeLocalSecondary( if len(state.Services) == 1 { svc = &state.Services[0] } + if svc != nil && svc.Protocol == ProtocolInvocationsWS { + return out, priority + } invokeArg, readmeHint := resolveInvokeArg(svc, "", readmeExists, priority) if readmeHint != nil { out = append(out, *readmeHint) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index bf8bc5078c0..83e0036940b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -482,6 +482,8 @@ func loadServiceProtocol(projectPath, relativePath string) string { switch strings.TrimSpace(p.Protocol) { case ProtocolResponses: return ProtocolResponses + case ProtocolInvocationsWS: + return ProtocolInvocationsWS case ProtocolInvocations: sawInvocations = true } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 4663ad937f2..b74cec0a04f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -596,6 +596,15 @@ protocols: `, want: ProtocolInvocations, }, + { + name: "single invocations_ws protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations_ws + version: "2.0.0" +`, + want: ProtocolInvocationsWS, + }, { name: "responses wins when both declared", manifest: `kind: hostedAgent From 95cde48aadd320c459a16fa01a8bb8c4e7ffe5a2 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:58:23 +0800 Subject: [PATCH 25/34] Preserve invocable protocol precedence in next steps --- .../internal/cmd/nextstep/state.go | 6 ++++- .../internal/cmd/nextstep/state_test.go | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 83e0036940b..037dfc7cee5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -478,12 +478,13 @@ func loadServiceProtocol(projectPath, relativePath string) string { } sawInvocations := false + sawInvocationsWS := false for _, p := range hosted.Protocols { switch strings.TrimSpace(p.Protocol) { case ProtocolResponses: return ProtocolResponses case ProtocolInvocationsWS: - return ProtocolInvocationsWS + sawInvocationsWS = true case ProtocolInvocations: sawInvocations = true } @@ -491,6 +492,9 @@ func loadServiceProtocol(projectPath, relativePath string) string { if sawInvocations { return ProtocolInvocations } + if sawInvocationsWS { + return ProtocolInvocationsWS + } return "" } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index b74cec0a04f..5c3f9ebd6d3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -616,6 +616,28 @@ protocols: `, want: ProtocolResponses, }, + { + name: "responses wins over invocations_ws regardless of order", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations_ws + version: "2.0.0" + - protocol: responses + version: "2.0.0" +`, + want: ProtocolResponses, + }, + { + name: "invocations wins over invocations_ws regardless of order", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations_ws + version: "2.0.0" + - protocol: invocations + version: "1.0.0" +`, + want: ProtocolInvocations, + }, { name: "empty protocols section", manifest: `kind: hostedAgent From ef716a97609fc6805582c7542553b1e0c93202ff Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:06:10 +0800 Subject: [PATCH 26/34] Suppress websocket invoke suggestions after run and deploy --- .../internal/cmd/nextstep/resolver.go | 12 +++++++- .../internal/cmd/nextstep/resolver_test.go | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index f33b09943c8..3767f2e8f7c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -333,6 +333,9 @@ func ResolveAfterRun(state *State, serviceName string, readmeExists func(relativ } svc := findService(state, serviceName) + if !serviceSupportsAzdInvoke(svc) { + return nil + } cachedPayload := "" if state.HasOpenAPI && state.OpenAPIPayload != "" { @@ -644,6 +647,9 @@ func ResolveAfterDeploy( // after shows matches the spec example output (lines 238-241). for i := range state.Services { svc := &state.Services[i] + if !serviceSupportsAzdInvoke(svc) { + continue + } cached := "" if cachedPayload != nil { @@ -678,6 +684,10 @@ func ResolveAfterDeploy( return out } +func serviceSupportsAzdInvoke(svc *ServiceState) bool { + return svc == nil || svc.Protocol != ProtocolInvocationsWS +} + func readmeCommand(relativePath string) string { rel := path.Clean(strings.ReplaceAll(relativePath, "\\", "/")) if rel == "" || rel == "." { @@ -773,7 +783,7 @@ func appendInvokeLocalSecondary( if len(state.Services) == 1 { svc = &state.Services[0] } - if svc != nil && svc.Protocol == ProtocolInvocationsWS { + if !serviceSupportsAzdInvoke(svc) { return out, priority } invokeArg, readmeHint := resolveInvokeArg(svc, "", readmeExists, priority) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index 231a60eb8cd..da25288e5db 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -770,6 +770,14 @@ func TestResolveAfterRun(t *testing.T) { `curl http://localhost:/invocations/docs/openapi.json`, }, }, + { + name: "invocations_ws protocol suppresses invoke suggestions", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocationsWS}}, + }, + serviceName: "echo", + want: nil, + }, { name: "unknown protocol, no README → placeholder + tip", state: &State{ @@ -998,6 +1006,14 @@ func TestResolveAfterDeploy(t *testing.T) { assert.Equal(t, "test the deployment", out[1].Description) }) + t.Run("single websocket agent suppresses deploy invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocationsWS}}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + }) + t.Run("single agent, no cached payload, README on disk → README then placeholder invoke", func(t *testing.T) { t.Parallel() state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolResponses}}} @@ -1061,6 +1077,19 @@ func TestResolveAfterDeploy(t *testing.T) { assert.Equal(t, "test beta", out[3].Description) }) + t.Run("multi-agent skips websocket invoke but keeps invocable services", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "alpha", Protocol: ProtocolInvocationsWS}, + {Name: "beta", Protocol: ProtocolResponses}, + }} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent show alpha", out[0].Command) + assert.Equal(t, "azd ai agent show beta", out[1].Command) + assert.Equal(t, `azd ai agent invoke beta ''`, out[2].Command) + }) + t.Run("multi-agent README hint placement → before the corresponding placeholder invoke", func(t *testing.T) { t.Parallel() state := &State{Services: []ServiceState{ From b72e28f8c2b7fd0574062729d80bcd38adc4d995 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:03:54 +0800 Subject: [PATCH 27/34] Handle non-invocable protocols consistently --- .../azure.ai.agents/internal/cmd/init.go | 11 +----- .../azure.ai.agents/internal/cmd/init_test.go | 17 ++++++--- .../internal/cmd/nextstep/resolver.go | 4 ++- .../internal/cmd/nextstep/resolver_test.go | 36 +++++++++++++++++++ .../internal/cmd/nextstep/state.go | 6 ++++ .../internal/cmd/nextstep/state_test.go | 20 +++++++++++ 6 files changed, 79 insertions(+), 15 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 6894b63040a..4f7c0a650b7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -20,7 +20,6 @@ import ( osExec "os/exec" "path/filepath" "regexp" - "slices" "strings" "time" @@ -662,13 +661,6 @@ func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.Proto if len(flagProtocols) == 0 { return []agent_yaml.ProtocolVersionRecord{{Protocol: "responses", Version: "2.0.0"}}, nil } - if slices.Contains(flagProtocols, "activity") { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - "--protocol activity is not supported with --image", - "use code init for activity agents, or choose a different protocol for --image", - ) - } protocols, err := resolveKnownProtocols(flagProtocols) if err != nil { return nil, err @@ -1538,8 +1530,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Directory to download the agent definition to (defaults to 'src/')") cmd.Flags().StringSliceVar(&flags.protocols, "protocol", nil, - "Protocols supported by the agent (e.g., 'responses', 'invocations', 'invocations_ws'). "+ - "Can be specified multiple times.") + fmt.Sprintf("Protocols supported by the agent (%s). Can be specified multiple times.", knownProtocolNames())) cmd.Flags().StringVar(&flags.deployMode, "deploy-mode", "", "Deployment mode: 'container' (Docker image) or 'code' (ZIP upload). Defaults to 'code' for Python/.NET projects in --no-prompt.") 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 baa94021e41..5a16c3f78dd 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 @@ -379,7 +379,7 @@ func TestSynthesizeImageManifestFile_RejectsUnknownProtocol(t *testing.T) { require.Contains(t, err.Error(), "unknown protocol") } -func TestSynthesizeImageManifestFile_RejectsActivityProtocol(t *testing.T) { +func TestSynthesizeImageManifestFile_AcceptsActivityProtocol(t *testing.T) { t.Parallel() manifestPath, cleanup, err := synthesizeImageManifestFile( @@ -387,10 +387,19 @@ func TestSynthesizeImageManifestFile_RejectsActivityProtocol(t *testing.T) { "myacr.azurecr.io/agents/my-agent:v1", []string{"activity"}, ) - require.Error(t, err) - require.Empty(t, manifestPath) + require.NoError(t, err) cleanup() - require.Contains(t, err.Error(), "--protocol activity is not supported with --image") + + content, err := os.ReadFile(manifestPath) + require.NoError(t, err) + template, err := agent_yaml.ExtractAgentDefinition(content) + require.NoError(t, err) + + containerAgent, ok := template.(agent_yaml.ContainerAgent) + require.True(t, ok, "synthesized template should be a ContainerAgent, got %T", template) + require.Equal(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity", Version: "2.0.0"}, + }, containerAgent.Protocols) } func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index 3767f2e8f7c..a275dd993a9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -20,6 +20,8 @@ const ( // ProtocolInvocationsWS is the value of `agent.yaml#protocol` for // bidirectional WebSocket /invocations_ws agents. ProtocolInvocationsWS = "invocations_ws" + // ProtocolActivity is the value of `agent.yaml#protocol` for Activity Protocol agents. + ProtocolActivity = "activity" // placeholderPayload is the single-quoted literal the resolver // emits as the body argument when no concrete payload is known — @@ -685,7 +687,7 @@ func ResolveAfterDeploy( } func serviceSupportsAzdInvoke(svc *ServiceState) bool { - return svc == nil || svc.Protocol != ProtocolInvocationsWS + return svc == nil || (svc.Protocol != ProtocolInvocationsWS && svc.Protocol != ProtocolActivity) } func readmeCommand(relativePath string) string { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index da25288e5db..22e2bfc532c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -345,6 +345,26 @@ func TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary(t *testing.T assert.Equal(t, `azd ai agent invoke --local ''`, out[1].Command) }) + for _, tt := range []struct { + name string + protocol string + }{ + {name: "invocations_ws", protocol: ProtocolInvocationsWS}, + {name: "activity", protocol: ProtocolActivity}, + } { + t.Run("single-agent "+tt.name+" protocol suppresses invoke hint", func(t *testing.T) { + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + Services: []ServiceState{{Name: "echo", Protocol: tt.protocol}}, + } + out := ResolveAfterInit(state, nil) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent run", out[0].Command) + assert.Equal(t, "azd deploy", out[1].Command) + }) + } + t.Run("multi-agent → invoke stays unqualified, uses placeholder payload", func(t *testing.T) { t.Parallel() state := &State{ @@ -778,6 +798,14 @@ func TestResolveAfterRun(t *testing.T) { serviceName: "echo", want: nil, }, + { + name: "activity protocol suppresses invoke suggestions", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivity}}, + }, + serviceName: "echo", + want: nil, + }, { name: "unknown protocol, no README → placeholder + tip", state: &State{ @@ -1014,6 +1042,14 @@ func TestResolveAfterDeploy(t *testing.T) { assert.Equal(t, "azd ai agent show echo", out[0].Command) }) + t.Run("single activity agent suppresses deploy invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivity}}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + }) + t.Run("single agent, no cached payload, README on disk → README then placeholder invoke", func(t *testing.T) { t.Parallel() state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolResponses}}} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 037dfc7cee5..d3b64bf598f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -478,6 +478,7 @@ func loadServiceProtocol(projectPath, relativePath string) string { } sawInvocations := false + sawActivity := false sawInvocationsWS := false for _, p := range hosted.Protocols { switch strings.TrimSpace(p.Protocol) { @@ -487,11 +488,16 @@ func loadServiceProtocol(projectPath, relativePath string) string { sawInvocationsWS = true case ProtocolInvocations: sawInvocations = true + case ProtocolActivity: + sawActivity = true } } if sawInvocations { return ProtocolInvocations } + if sawActivity { + return ProtocolActivity + } if sawInvocationsWS { return ProtocolInvocationsWS } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 5c3f9ebd6d3..32dddd457ac 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -605,6 +605,15 @@ protocols: `, want: ProtocolInvocationsWS, }, + { + name: "single activity protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: activity + version: "2.0.0" +`, + want: ProtocolActivity, + }, { name: "responses wins when both declared", manifest: `kind: hostedAgent @@ -635,6 +644,17 @@ protocols: version: "2.0.0" - protocol: invocations version: "1.0.0" +`, + want: ProtocolInvocations, + }, + { + name: "invocations wins over activity regardless of order", + manifest: `kind: hostedAgent +protocols: + - protocol: activity + version: "2.0.0" + - protocol: invocations + version: "1.0.0" `, want: ProtocolInvocations, }, From 8147335c2e7eb4b857641e3e577473af85df90e2 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:16:20 +0800 Subject: [PATCH 28/34] Fix activity image manifest test cleanup --- cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5a16c3f78dd..7d5bf3a5bdf 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 @@ -388,7 +388,7 @@ func TestSynthesizeImageManifestFile_AcceptsActivityProtocol(t *testing.T) { []string{"activity"}, ) require.NoError(t, err) - cleanup() + defer cleanup() content, err := os.ReadFile(manifestPath) require.NoError(t, err) From b1bd083d064c90d6c6ebe92a5a307634b4d8b83f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:41:31 +0800 Subject: [PATCH 29/34] Handle legacy activity protocol in next steps --- .../internal/cmd/nextstep/resolver.go | 6 +++++- .../internal/cmd/nextstep/resolver_test.go | 17 +++++++++++++++++ .../internal/cmd/nextstep/state.go | 2 +- .../internal/cmd/nextstep/state_test.go | 9 +++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index a275dd993a9..38340d30bab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -22,6 +22,8 @@ const ( ProtocolInvocationsWS = "invocations_ws" // ProtocolActivity is the value of `agent.yaml#protocol` for Activity Protocol agents. ProtocolActivity = "activity" + // ProtocolActivityLegacy is the legacy value accepted for Activity Protocol agents. + ProtocolActivityLegacy = "activity_protocol" // placeholderPayload is the single-quoted literal the resolver // emits as the body argument when no concrete payload is known — @@ -687,7 +689,9 @@ func ResolveAfterDeploy( } func serviceSupportsAzdInvoke(svc *ServiceState) bool { - return svc == nil || (svc.Protocol != ProtocolInvocationsWS && svc.Protocol != ProtocolActivity) + return svc == nil || (svc.Protocol != ProtocolInvocationsWS && + svc.Protocol != ProtocolActivity && + svc.Protocol != ProtocolActivityLegacy) } func readmeCommand(relativePath string) string { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index 22e2bfc532c..5990f97c546 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -351,6 +351,7 @@ func TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary(t *testing.T }{ {name: "invocations_ws", protocol: ProtocolInvocationsWS}, {name: "activity", protocol: ProtocolActivity}, + {name: "activity_protocol", protocol: ProtocolActivityLegacy}, } { t.Run("single-agent "+tt.name+" protocol suppresses invoke hint", func(t *testing.T) { t.Parallel() @@ -806,6 +807,14 @@ func TestResolveAfterRun(t *testing.T) { serviceName: "echo", want: nil, }, + { + name: "legacy activity protocol suppresses invoke suggestions", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivityLegacy}}, + }, + serviceName: "echo", + want: nil, + }, { name: "unknown protocol, no README → placeholder + tip", state: &State{ @@ -1050,6 +1059,14 @@ func TestResolveAfterDeploy(t *testing.T) { assert.Equal(t, "azd ai agent show echo", out[0].Command) }) + t.Run("single legacy activity agent suppresses deploy invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivityLegacy}}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + }) + t.Run("single agent, no cached payload, README on disk → README then placeholder invoke", func(t *testing.T) { t.Parallel() state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolResponses}}} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index d3b64bf598f..c47f333edb1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -488,7 +488,7 @@ func loadServiceProtocol(projectPath, relativePath string) string { sawInvocationsWS = true case ProtocolInvocations: sawInvocations = true - case ProtocolActivity: + case ProtocolActivity, ProtocolActivityLegacy: sawActivity = true } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 32dddd457ac..e4fe025633f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -611,6 +611,15 @@ protocols: protocols: - protocol: activity version: "2.0.0" +`, + want: ProtocolActivity, + }, + { + name: "single legacy activity protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: activity_protocol + version: "1.0.0" `, want: ProtocolActivity, }, From 991c2155947241f5a5a0b10dcbb64050c3e2fdf3 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:57:29 +0800 Subject: [PATCH 30/34] Load protocols from inline agent config for next steps --- .../internal/cmd/nextstep/state.go | 29 ++++++++- .../internal/cmd/nextstep/state_test.go | 63 +++++++++++++++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index c47f333edb1..71e1b3eac74 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -442,7 +442,7 @@ func collectServices( Name: svc.Name, Host: svc.Host, RelativePath: svc.RelativePath, - Protocol: loadServiceProtocol(project.Path, svc.RelativePath), + Protocol: loadServiceProtocol(project.Path, svc), IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs), }) } @@ -460,7 +460,28 @@ func collectServices( // manifest declares multiple protocols, ProtocolResponses wins over // ProtocolInvocations so the suggested payload works on the broadest set of // agents. -func loadServiceProtocol(projectPath, relativePath string) string { +func loadServiceProtocol(projectPath string, svc *azdext.ServiceConfig) string { + if protocol := loadServiceProtocolFromConfig(svc); protocol != "" { + return protocol + } + if svc == nil { + return "" + } + return loadServiceProtocolFromFile(projectPath, svc.RelativePath) +} + +func loadServiceProtocolFromConfig(svc *azdext.ServiceConfig) string { + if svc == nil || svc.Config == nil { + return "" + } + data, err := yaml.Marshal(svc.Config.AsMap()) + if err != nil { + return "" + } + return loadServiceProtocolFromBytes(data) +} + +func loadServiceProtocolFromFile(projectPath, relativePath string) string { if projectPath == "" { return "" } @@ -472,6 +493,10 @@ func loadServiceProtocol(projectPath, relativePath string) string { if err != nil { return "" } + return loadServiceProtocolFromBytes(data) +} + +func loadServiceProtocolFromBytes(data []byte) string { var hosted agent_yaml.ContainerAgent if err := yaml.Unmarshal(data, &hosted); err != nil { return "" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index e4fe025633f..73b9c557b3e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -13,6 +13,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // fakeSource is a hand-rolled Source for table-driven tests. @@ -711,7 +712,7 @@ protocols: 0o600, )) } - got := loadServiceProtocol(projectRoot, relPath) + got := loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: relPath}) assert.Equal(t, tt.want, got) }) } @@ -720,7 +721,7 @@ protocols: func TestLoadServiceProtocol_EmptyArgs(t *testing.T) { t.Parallel() - assert.Equal(t, "", loadServiceProtocol("", "echo")) + assert.Equal(t, "", loadServiceProtocol("", &azdext.ServiceConfig{RelativePath: "echo"})) } func TestLoadServiceProtocol_RootRelativePath(t *testing.T) { @@ -733,8 +734,8 @@ func TestLoadServiceProtocol_RootRelativePath(t *testing.T) { 0o600, )) - assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, "")) - assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, ".")) + assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, &azdext.ServiceConfig{})) + assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: "."})) } func TestLoadServiceProtocol_RejectsTraversal(t *testing.T) { @@ -751,7 +752,32 @@ func TestLoadServiceProtocol_RejectsTraversal(t *testing.T) { 0o600, )) - assert.Equal(t, "", loadServiceProtocol(projectRoot, "../outside")) + assert.Equal(t, "", loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: "../outside"})) +} + +func TestLoadServiceProtocol_InlineConfigWinsOverAgentYaml(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + relPath := "echo" + svcDir := filepath.Join(projectRoot, relPath) + require.NoError(t, os.MkdirAll(svcDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(svcDir, "agent.yaml"), + []byte("kind: hostedAgent\nprotocols:\n - protocol: responses\n version: \"2.0.0\"\n"), + 0o600, + )) + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + got := loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: relPath, Config: config}) + assert.Equal(t, ProtocolInvocationsWS, got) } func TestAssembleState_PopulatesProtocolFromAgentYaml(t *testing.T) { @@ -781,6 +807,33 @@ func TestAssembleState_PopulatesProtocolFromAgentYaml(t *testing.T) { assert.Equal(t, ProtocolInvocations, state.Services[0].Protocol) } +func TestAssembleState_PopulatesProtocolFromInlineServiceConfig(t *testing.T) { + t.Parallel() + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: t.TempDir(), + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, Config: config}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + require.Len(t, state.Services, 1) + assert.Equal(t, ProtocolInvocationsWS, state.Services[0].Protocol) +} + func TestExtractAgentYamlEnvRefs(t *testing.T) { t.Parallel() From 6ee9c9cc149a15f5f81c3b61490453c7f6ca1017 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:32:45 +0800 Subject: [PATCH 31/34] Load next-step protocols from inline service properties --- .../internal/cmd/nextstep/state.go | 29 +++++++++++++++++-- .../internal/cmd/nextstep/state_test.go | 26 ++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 71e1b3eac74..a64992a953b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -20,6 +20,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "go.yaml.in/yaml/v3" + "google.golang.org/protobuf/types/known/structpb" ) const ( @@ -471,16 +472,40 @@ func loadServiceProtocol(projectPath string, svc *azdext.ServiceConfig) string { } func loadServiceProtocolFromConfig(svc *azdext.ServiceConfig) string { - if svc == nil || svc.Config == nil { + props := nextStepServiceConfigProps(svc) + if len(props) == 0 { return "" } - data, err := yaml.Marshal(svc.Config.AsMap()) + data, err := yaml.Marshal(props) if err != nil { return "" } return loadServiceProtocolFromBytes(data) } +func nextStepServiceConfigProps(svc *azdext.ServiceConfig) map[string]any { + if svc == nil { + return nil + } + inline := svc.GetAdditionalProperties() + if structHasKind(inline) { + return inline.AsMap() + } + cfg := svc.GetConfig() + if structHasKind(cfg) { + return cfg.AsMap() + } + return nil +} + +func structHasKind(s *structpb.Struct) bool { + if s == nil { + return false + } + v, ok := s.GetFields()["kind"] + return ok && strings.TrimSpace(v.GetStringValue()) != "" +} + func loadServiceProtocolFromFile(projectPath, relativePath string) string { if projectPath == "" { return "" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 73b9c557b3e..e27958b68fc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -755,7 +755,7 @@ func TestLoadServiceProtocol_RejectsTraversal(t *testing.T) { assert.Equal(t, "", loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: "../outside"})) } -func TestLoadServiceProtocol_InlineConfigWinsOverAgentYaml(t *testing.T) { +func TestLoadServiceProtocol_InlineAdditionalPropertiesWinOverAgentYaml(t *testing.T) { t.Parallel() projectRoot := t.TempDir() @@ -776,7 +776,25 @@ func TestLoadServiceProtocol_InlineConfigWinsOverAgentYaml(t *testing.T) { }) require.NoError(t, err) - got := loadServiceProtocol(projectRoot, &azdext.ServiceConfig{RelativePath: relPath, Config: config}) + got := loadServiceProtocol(projectRoot, &azdext.ServiceConfig{ + RelativePath: relPath, + AdditionalProperties: config, + }) + assert.Equal(t, ProtocolInvocationsWS, got) +} + +func TestLoadServiceProtocol_LegacyConfigFallback(t *testing.T) { + t.Parallel() + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + got := loadServiceProtocol(t.TempDir(), &azdext.ServiceConfig{Config: config}) assert.Equal(t, ProtocolInvocationsWS, got) } @@ -807,7 +825,7 @@ func TestAssembleState_PopulatesProtocolFromAgentYaml(t *testing.T) { assert.Equal(t, ProtocolInvocations, state.Services[0].Protocol) } -func TestAssembleState_PopulatesProtocolFromInlineServiceConfig(t *testing.T) { +func TestAssembleState_PopulatesProtocolFromInlineAdditionalProperties(t *testing.T) { t.Parallel() config, err := structpb.NewStruct(map[string]any{ @@ -823,7 +841,7 @@ func TestAssembleState_PopulatesProtocolFromInlineServiceConfig(t *testing.T) { project: &azdext.ProjectConfig{ Path: t.TempDir(), Services: map[string]*azdext.ServiceConfig{ - "echo": {Name: "echo", Host: agentHost, Config: config}, + "echo": {Name: "echo", Host: agentHost, AdditionalProperties: config}, }, }, } From d260d0cd28461851e90de60ae82c6cb81647b7e3 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:50:20 +0800 Subject: [PATCH 32/34] Emit explicit invoke protocol for mixed services --- .../internal/cmd/nextstep/resolver.go | 11 +++- .../internal/cmd/nextstep/resolver_test.go | 22 ++++++++ .../internal/cmd/nextstep/state.go | 52 +++++++++++-------- .../internal/cmd/nextstep/state_test.go | 29 +++++++++++ 4 files changed, 90 insertions(+), 24 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index 38340d30bab..34671f45773 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -353,7 +353,7 @@ func ResolveAfterRun(state *State, serviceName string, readmeExists func(relativ out = append(out, *readmeHint) } out = append(out, Suggestion{ - Command: fmt.Sprintf("azd ai agent invoke --local %s", invokeArg), + Command: fmt.Sprintf("azd ai agent invoke --local %s%s", invokeProtocolFlag(svc), invokeArg), Description: "send a sample request to the running agent", Priority: 10, }) @@ -678,7 +678,7 @@ func ResolveAfterDeploy( } out = append(out, Suggestion{ - Command: fmt.Sprintf("azd ai agent invoke %s %s", svc.Name, invokeArg), + Command: fmt.Sprintf("azd ai agent invoke %s %s%s", svc.Name, invokeProtocolFlag(svc), invokeArg), Description: desc, Priority: priority, }) @@ -694,6 +694,13 @@ func serviceSupportsAzdInvoke(svc *ServiceState) bool { svc.Protocol != ProtocolActivityLegacy) } +func invokeProtocolFlag(svc *ServiceState) string { + if svc == nil || !svc.MultiProtocol || svc.Protocol == "" { + return "" + } + return fmt.Sprintf("--protocol %s ", svc.Protocol) +} + func readmeCommand(relativePath string) string { rel := path.Clean(strings.ReplaceAll(relativePath, "\\", "/")) if rel == "" || rel == "." { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index 5990f97c546..fc44b5b4e28 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -791,6 +791,17 @@ func TestResolveAfterRun(t *testing.T) { `curl http://localhost:/invocations/docs/openapi.json`, }, }, + { + name: "multi-protocol responses service adds explicit protocol flag", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolResponses, MultiProtocol: true}}, + }, + serviceName: "echo", + want: []string{ + `azd ai agent invoke --local --protocol responses ''`, + `curl http://localhost:/invocations/docs/openapi.json`, + }, + }, { name: "invocations_ws protocol suppresses invoke suggestions", state: &State{ @@ -1051,6 +1062,17 @@ func TestResolveAfterDeploy(t *testing.T) { assert.Equal(t, "azd ai agent show echo", out[0].Command) }) + t.Run("multi-protocol responses service adds deploy protocol flag", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{ + Name: "echo", Protocol: ProtocolResponses, MultiProtocol: true, + }}} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, `azd ai agent invoke echo --protocol responses ''`, out[1].Command) + }) + t.Run("single activity agent suppresses deploy invoke", func(t *testing.T) { t.Parallel() state := &State{Services: []ServiceState{{Name: "echo", Protocol: ProtocolActivity}}} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index a64992a953b..0f2739ff1a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -439,12 +439,14 @@ func collectServices( if svc == nil || svc.Host != agentHost { continue } + protocol, multiProtocol := loadServiceProtocolInfo(project.Path, svc) services = append(services, ServiceState{ - Name: svc.Name, - Host: svc.Host, - RelativePath: svc.RelativePath, - Protocol: loadServiceProtocol(project.Path, svc), - IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs), + Name: svc.Name, + Host: svc.Host, + RelativePath: svc.RelativePath, + Protocol: protocol, + MultiProtocol: multiProtocol, + IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs), }) } @@ -462,23 +464,28 @@ func collectServices( // ProtocolInvocations so the suggested payload works on the broadest set of // agents. func loadServiceProtocol(projectPath string, svc *azdext.ServiceConfig) string { - if protocol := loadServiceProtocolFromConfig(svc); protocol != "" { - return protocol + protocol, _ := loadServiceProtocolInfo(projectPath, svc) + return protocol +} + +func loadServiceProtocolInfo(projectPath string, svc *azdext.ServiceConfig) (string, bool) { + if protocol, multiProtocol := loadServiceProtocolFromConfig(svc); protocol != "" { + return protocol, multiProtocol } if svc == nil { - return "" + return "", false } return loadServiceProtocolFromFile(projectPath, svc.RelativePath) } -func loadServiceProtocolFromConfig(svc *azdext.ServiceConfig) string { +func loadServiceProtocolFromConfig(svc *azdext.ServiceConfig) (string, bool) { props := nextStepServiceConfigProps(svc) if len(props) == 0 { - return "" + return "", false } data, err := yaml.Marshal(props) if err != nil { - return "" + return "", false } return loadServiceProtocolFromBytes(data) } @@ -506,26 +513,27 @@ func structHasKind(s *structpb.Struct) bool { return ok && strings.TrimSpace(v.GetStringValue()) != "" } -func loadServiceProtocolFromFile(projectPath, relativePath string) string { +func loadServiceProtocolFromFile(projectPath, relativePath string) (string, bool) { if projectPath == "" { - return "" + return "", false } manifestPath, err := paths.JoinAllowRoot(projectPath, relativePath, "agent.yaml") if err != nil { - return "" + return "", false } data, err := os.ReadFile(manifestPath) //nolint:gosec // path is validated under the project root if err != nil { - return "" + return "", false } return loadServiceProtocolFromBytes(data) } -func loadServiceProtocolFromBytes(data []byte) string { +func loadServiceProtocolFromBytes(data []byte) (string, bool) { var hosted agent_yaml.ContainerAgent if err := yaml.Unmarshal(data, &hosted); err != nil { - return "" + return "", false } + multiProtocol := len(hosted.Protocols) > 1 sawInvocations := false sawActivity := false @@ -533,7 +541,7 @@ func loadServiceProtocolFromBytes(data []byte) string { for _, p := range hosted.Protocols { switch strings.TrimSpace(p.Protocol) { case ProtocolResponses: - return ProtocolResponses + return ProtocolResponses, multiProtocol case ProtocolInvocationsWS: sawInvocationsWS = true case ProtocolInvocations: @@ -543,15 +551,15 @@ func loadServiceProtocolFromBytes(data []byte) string { } } if sawInvocations { - return ProtocolInvocations + return ProtocolInvocations, multiProtocol } if sawActivity { - return ProtocolActivity + return ProtocolActivity, multiProtocol } if sawInvocationsWS { - return ProtocolInvocationsWS + return ProtocolInvocationsWS, multiProtocol } - return "" + return "", multiProtocol } // detectMissingVars walks each service's agent.yaml environment_variables diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index e27958b68fc..6a912bae4fe 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -852,6 +852,35 @@ func TestAssembleState_PopulatesProtocolFromInlineAdditionalProperties(t *testin assert.Equal(t, ProtocolInvocationsWS, state.Services[0].Protocol) } +func TestAssembleState_MarksInlineMultiProtocolService(t *testing.T) { + t.Parallel() + + config, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "protocols": []any{ + map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}, + map[string]any{"protocol": "responses", "version": "2.0.0"}, + }, + }) + require.NoError(t, err) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: t.TempDir(), + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, AdditionalProperties: config}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + require.Len(t, state.Services, 1) + assert.Equal(t, ProtocolResponses, state.Services[0].Protocol) + assert.True(t, state.Services[0].MultiProtocol) +} + func TestExtractAgentYamlEnvRefs(t *testing.T) { t.Parallel() From e610152742a88dd2d45b4748064a2595349de321 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:04:51 +0800 Subject: [PATCH 33/34] Fix multi-protocol local invoke hints --- .../internal/cmd/nextstep/resolver.go | 2 +- .../internal/cmd/nextstep/resolver_test.go | 14 ++++++++++++++ .../azure.ai.agents/internal/cmd/nextstep/types.go | 11 ++++++----- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index 34671f45773..361bdcb9c6a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -805,7 +805,7 @@ func appendInvokeLocalSecondary( priority++ } out = append(out, Suggestion{ - Command: fmt.Sprintf("azd ai agent invoke --local %s", invokeArg), + Command: fmt.Sprintf("azd ai agent invoke --local %s%s", invokeProtocolFlag(svc), invokeArg), Description: "test it in another terminal", Priority: priority, }) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index fc44b5b4e28..0eb89375e65 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -333,6 +333,20 @@ func TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary(t *testing.T assert.Equal(t, `azd ai agent invoke --local ''`, out[1].Command) }) + t.Run("single-agent multi-protocol responses adds explicit protocol flag", func(t *testing.T) { + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + Services: []ServiceState{{ + Name: "echo", Protocol: ProtocolResponses, MultiProtocol: true, + }}, + } + out := ResolveAfterInit(state, nil) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent run", out[0].Command) + assert.Equal(t, `azd ai agent invoke --local --protocol responses ''`, out[1].Command) + }) + t.Run("single-agent invocations protocol → invoke uses placeholder", func(t *testing.T) { t.Parallel() state := &State{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go index 52c91382201..0eac6c2d0dc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go @@ -210,9 +210,10 @@ type ResourceRef struct { // underscores — the convention used by the deploy-time env-var writer in // project/service_target_agent.go. type ServiceState struct { - Name string - Host string - Protocol string - RelativePath string - IsDeployed bool + Name string + Host string + Protocol string + MultiProtocol bool + RelativePath string + IsDeployed bool } From aa2a0616c1159864c7d6d50bf5457b5e6d504f70 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:37:26 +0800 Subject: [PATCH 34/34] Use test context in inline-protocol assemble-state tests Replace context.Background() with t.Context() in the two new inline AdditionalProperties assemble-state tests, per the Go 1.26 test-context convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 480247f9-174e-40ba-ab27-9a688d0675bf --- .../azure.ai.agents/internal/cmd/nextstep/state_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 6a912bae4fe..e118f83a8bd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -846,7 +846,7 @@ func TestAssembleState_PopulatesProtocolFromInlineAdditionalProperties(t *testin }, } - state, errs := assembleState(context.Background(), src) + state, errs := assembleState(t.Context(), src) require.Empty(t, errs) require.Len(t, state.Services, 1) assert.Equal(t, ProtocolInvocationsWS, state.Services[0].Protocol) @@ -874,7 +874,7 @@ func TestAssembleState_MarksInlineMultiProtocolService(t *testing.T) { }, } - state, errs := assembleState(context.Background(), src) + state, errs := assembleState(t.Context(), src) require.Empty(t, errs) require.Len(t, state.Services, 1) assert.Equal(t, ProtocolResponses, state.Services[0].Protocol)