From 004e18bfeb6a32ad29df742dd48d5bc18831f9d7 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 13:18:15 +0800 Subject: [PATCH 1/3] fix: prompt provision after connection init --- .../extensions/azure.ai.agents/CHANGELOG.md | 8 ++ .../azure.ai.agents/internal/cmd/init.go | 13 ++- .../internal/cmd/init_from_code.go | 2 +- .../internal/cmd/nextstep/resolver_test.go | 9 ++ .../internal/cmd/pending_provision.go | 27 ++++++ .../internal/cmd/pending_provision_test.go | 83 +++++++++++++++++++ .../internal/cmd/resource_services.go | 26 +++--- .../internal/cmd/resource_services_test.go | 70 ++++++++++++---- 8 files changed, 208 insertions(+), 30 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index a5d3569ccf9..88cade5e1d1 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,13 @@ # Release History +## 1.0.0-beta.12 (Unreleased) + +### Bugs Fixed + +- Suggest `azd provision` after init writes standalone + `azure.ai.connection` services, based on the services actually emitted; + successful provision clears the pending signal. + ## 1.0.0-beta.11 (2026-08-20) ### Features Added 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 af9bce3a2b0..e62e5c77c49 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -3412,14 +3412,21 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa // Emit the sibling Foundry resource services (project + deployments, // connections, toolboxes) and wire the agent's uses: to them. A selected // existing project contributes its endpoint so provision reuses it. - if err := emitResourceServices( + emittedConnections, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), resourceDeployments, resourceConnections, resourceToolboxes, - ); err != nil { + ) + if err != nil { return err } + recordPendingConnectionProvision( + ctx, + a.azdClient, + a.environment.Name, + emittedConnections, + ) printAgentAddedMessage(agentDef.Name) @@ -3491,7 +3498,7 @@ func (a *InitAction) addVoiceAgentToProject( // project. Voice init emits no deployment/connection/toolbox siblings; managed // models are service-hosted, and BYOM model deployments are referenced from // azure.yaml and must already exist. - if err := emitResourceServices( + if _, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), 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 a37a34a1beb..e2ed85ac68f 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 @@ -886,7 +886,7 @@ func (a *InitFromCodeAction) addToProject( // Emit the sibling azure.ai.project service carrying the model deployments // and wire the agent's uses: to it. A selected existing project contributes // its endpoint so provision reuses it instead of creating a new project. - if err := emitResourceServices( + if _, err := emitResourceServices( ctx, a.azdClient, agentServiceName, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), 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 6032781f22a..3e7a241022e 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 @@ -117,6 +117,15 @@ func TestResolveAfterInit(t *testing.T) { wantPrimaryHas: "azd provision", wantTrailing: "azd deploy", }, + { + name: "new connection in existing project → provision", + state: &State{ + HasProjectEndpoint: true, + PendingProvisionReasons: []string{"connection"}, + }, + wantPrimaryHas: "azd provision", + wantTrailing: "azd deploy", + }, { name: "provision needed with missing Azure context → env set before provision", state: &State{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go index 948e2b5fa49..6d8a0793837 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "log" "slices" "strings" @@ -45,6 +46,7 @@ const ( pendingReasonModelDeployment = "model_deployment" pendingReasonACR = "acr" pendingReasonAppInsights = "app_insights" + pendingReasonConnection = "connection" ) // parsePendingProvisionReasons splits the comma-separated env-var @@ -107,6 +109,31 @@ func addPendingProvisionReason( }) } +// recordPendingConnectionProvision records a pending +// connection reason after init writes at least one service. +// Write failures are warnings so init is not rolled back. +func recordPendingConnectionProvision( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + emitted int, +) { + if emitted <= 0 { + return + } + if _, err := addPendingProvisionReason( + ctx, + azdClient, + envName, + pendingReasonConnection, + ); err != nil { + log.Printf( + "warning: could not record pending connection provision: %v", + err, + ) + } +} + // removePendingProvisionReason drops a reason tag from the // AI_AGENT_PENDING_PROVISION env var. Idempotent: removing a tag // that was not present is a no-op (no write performed). Used when diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go index 569ae089302..9994b193be7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go @@ -204,6 +204,89 @@ func TestRemovePendingProvisionReason(t *testing.T) { }) } +func TestRecordPendingConnectionProvision(t *testing.T) { + t.Parallel() + + t.Run("emitted writes connection reason", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{ + "test-env": {Name: "test-env"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + recordPendingConnectionProvision( + context.Background(), azdClient, "test-env", 1) + require.Equal( + t, + pendingReasonConnection, + envServer.values["test-env"][pendingProvisionEnvVar], + ) + }) + + t.Run("zero emitted is no-op", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{ + "test-env": {Name: "test-env"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + recordPendingConnectionProvision( + context.Background(), azdClient, "test-env", 0) + _, hit := envServer.values["test-env"][pendingProvisionEnvVar] + require.False(t, hit) + }) + + t.Run("zero emitted preserves existing reason", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{ + "test-env": {Name: "test-env"}, + }, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "connection"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + recordPendingConnectionProvision( + context.Background(), azdClient, "test-env", 0) + require.Equal( + t, + "connection", + envServer.values["test-env"][pendingProvisionEnvVar], + ) + }) + + t.Run("sorts with existing reasons", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{ + "test-env": {Name: "test-env"}, + }, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "project"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + recordPendingConnectionProvision( + context.Background(), azdClient, "test-env", 2) + require.Equal( + t, + "connection,project", + envServer.values["test-env"][pendingProvisionEnvVar], + ) + }) +} + func TestClearPendingProvisionReasons(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 83b5c8ae13b..39709ba51ca 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -59,8 +59,9 @@ func emitResourceServices( deployments []project.Deployment, connections []project.Connection, toolboxes []project.Toolbox, -) error { +) (int, error) { var agentUses []string + emittedConnections := 0 // Track every azure.yaml service key we emit so two resource names that // sanitize to the same key (e.g. "my conn" and "myconn") fail fast instead @@ -93,14 +94,14 @@ func emitResourceServices( Deployments: deployments, }) if err != nil { - return fmt.Errorf("marshaling project service config: %w", err) + return 0, fmt.Errorf("marshaling project service config: %w", err) } projectServiceName := resolveProjectServiceKey(ctx, azdClient, projectName, agentServiceName) if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { - return err + return 0, err } if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { - return err + return 0, err } agentUses = append(agentUses, projectServiceName) @@ -119,16 +120,17 @@ func emitResourceServices( continue } if err := reserveServiceName(usedNames, connName, fmt.Sprintf("connection %q", conn.Name)); err != nil { - return err + return 0, err } connCfg, err := project.MarshalStruct(&conn) if err != nil { - return fmt.Errorf("marshaling connection service %q config: %w", connName, err) + return 0, fmt.Errorf("marshaling connection service %q config: %w", connName, err) } if err := addResourceService(ctx, azdClient, connName, AiConnectionHost, connCfg, siblingUses); err != nil { - return err + return 0, err } agentUses = append(agentUses, connName) + emittedConnections++ } for i := range toolboxes { @@ -142,14 +144,14 @@ func emitResourceServices( continue } if err := reserveServiceName(usedNames, toolboxName, fmt.Sprintf("toolbox %q", toolbox.Name)); err != nil { - return err + return 0, err } toolboxCfg, err := project.MarshalStruct(&toolbox) if err != nil { - return fmt.Errorf("marshaling toolbox service %q config: %w", toolboxName, err) + return 0, fmt.Errorf("marshaling toolbox service %q config: %w", toolboxName, err) } if err := addResourceService(ctx, azdClient, toolboxName, AiToolboxHost, toolboxCfg, siblingUses); err != nil { - return err + return 0, err } agentUses = append(agentUses, toolboxName) } @@ -157,11 +159,11 @@ func emitResourceServices( // Wire the agent service to its resource siblings so azd walks them first. if len(agentUses) > 0 && agentServiceName != "" { if err := setServiceUses(ctx, azdClient, agentServiceName, agentUses); err != nil { - return err + return 0, err } } - return nil + return emittedConnections, nil } // resolveProjectServiceKey picks the azure.yaml service key for the single diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index e01c46c4801..80f7d62461c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -588,7 +588,7 @@ func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil) require.NoError(t, err) server.mu.Lock() @@ -610,7 +610,7 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { client := newProjectRecorderClient(t, server) conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, conns, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, conns, nil) require.NoError(t, err) server.mu.Lock() @@ -626,6 +626,38 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { assert.Equal(t, []string{aiProjectServiceName, "myconn"}, server.uses["myagent"]) } +func TestEmitResourceServices_CountsEmittedConnections(t *testing.T) { + t.Parallel() + + t.Run("valid connection returns 1", func(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} + + got, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, conns, nil) + require.NoError(t, err) + assert.Equal(t, 1, got) + }) + + t.Run("blank name returns 0", func(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + conns := []project.Connection{{Name: " ", Category: "ApiKey"}} + + got, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, conns, nil) + require.NoError(t, err) + assert.Equal(t, 0, got) + + server.mu.Lock() + defer server.mu.Unlock() + for _, svc := range server.added { + assert.NotEqual(t, AiConnectionHost, svc.Host) + } + }) +} + // TestEmitResourceServices_WritesServiceLevelProps verifies resource services are // written with their keys composed at the service level (inline via // AdditionalProperties, matching the agent service shape and the config:false @@ -643,7 +675,9 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, }} conns := []project.Connection{{Name: "myconn", Category: "ApiKey", Target: "https://example", AuthType: "ApiKey"}} - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", deployments, conns, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", "", deployments, conns, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -658,7 +692,7 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { // Init must write a project shape the owning extension can parse. var projectCfg project.ServiceTargetAgentConfig - err := project.UnmarshalStruct( + err = project.UnmarshalStruct( project.ServiceConfigProps(services["ai-project"]), &projectCfg, ) @@ -686,7 +720,9 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", endpoint, nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", endpoint, nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -702,7 +738,9 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -726,8 +764,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my-foundry-proj", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "my-foundry-proj", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -746,8 +785,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { // A different project name is supplied, but the existing key wins so a // repeated init does not create a second project service. - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "a-new-name", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "a-new-name", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -759,8 +799,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my agent", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "my agent", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -773,8 +814,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() From 00aa3bff6bd34fe78769beefdd19ad3222bf9858 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 15:08:46 +0800 Subject: [PATCH 2/3] fix: remove pending provision changelog entry --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 88cade5e1d1..a5d3569ccf9 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,13 +1,5 @@ # Release History -## 1.0.0-beta.12 (Unreleased) - -### Bugs Fixed - -- Suggest `azd provision` after init writes standalone - `azure.ai.connection` services, based on the services actually emitted; - successful provision clears the pending signal. - ## 1.0.0-beta.11 (2026-08-20) ### Features Added From 1bf5a1bbfd00b346caf7bb87c9898005ef76a949 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 15:11:23 +0800 Subject: [PATCH 3/3] fix: clarify pending provision comments --- .../azure.ai.agents/internal/cmd/pending_provision.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go index 6d8a0793837..5bb2c84d30c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go @@ -109,9 +109,9 @@ func addPendingProvisionReason( }) } -// recordPendingConnectionProvision records a pending -// connection reason after init writes at least one service. -// Write failures are warnings so init is not rolled back. +// recordPendingConnectionProvision marks connections that need +// provision after init writes a connection service. +// A signal write failure only produces a warning. func recordPendingConnectionProvision( ctx context.Context, azdClient *azdext.AzdClient,