From d8876899c71cd2fd3f5250beabab3e131a88ba4d Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 20 Jul 2026 09:51:32 -0400 Subject: [PATCH 1/5] fix(ai-agents): preserve service config during container defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fed9e97b-e79b-4889-ac76-0d9a428599cd --- .../azure.ai.agents/internal/cmd/listen.go | 21 +++- .../internal/cmd/listen_test.go | 105 ++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index d968f5f98ae..9cb681e29e5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -760,17 +760,30 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, result.Cpu = project.DefaultCpu } + containerPath := "container" + if props := svc.GetAdditionalProperties(); props == nil || len(props.GetFields()) == 0 { + if cfg := svc.GetConfig(); cfg != nil && len(cfg.GetFields()) > 0 { + containerPath = "config.container" + } + } + // Persist the resolved container settings back onto the service's inline // properties, preserving the agent definition and other config keys. if err := project.SetAgentContainerSettings(svc, &project.ContainerSettings{Resources: result}); err != nil { return fmt.Errorf("failed to update agent container settings: %w", err) } - // Need to add the service config back to the project for use further down the pipeline - req := &azdext.AddServiceRequest{Service: svc} + containerValue := project.ServiceConfigProps(svc).GetFields()["container"] + if containerValue == nil { + return errors.New("failed to read updated agent container settings") + } - if _, err := azdClient.Project().AddService(ctx, req); err != nil { - return fmt.Errorf("adding agent service to project: %w", err) + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: svc.GetName(), + Path: containerPath, + Value: containerValue, + }); err != nil { + return fmt.Errorf("persisting agent container settings: %w", err) } return nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go index 3359f2393f4..0e8912dd5c4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go @@ -5,6 +5,7 @@ package cmd import ( "bytes" + "context" "os" "path/filepath" "strings" @@ -17,8 +18,112 @@ 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" ) +type containerSettingsProjectServer struct { + azdext.UnimplementedProjectServiceServer + + mu sync.Mutex + addServiceCalls int + setServiceRequests []*azdext.SetServiceConfigValueRequest +} + +func (s *containerSettingsProjectServer) AddService( + _ context.Context, + _ *azdext.AddServiceRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.addServiceCalls++ + return &azdext.EmptyResponse{}, nil +} + +func (s *containerSettingsProjectServer) SetServiceConfigValue( + _ context.Context, + req *azdext.SetServiceConfigValueRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.setServiceRequests = append(s.setServiceRequests, req) + return &azdext.EmptyResponse{}, nil +} + +func TestPopulateContainerSettings_UsesTargetedConfigUpdate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + legacy bool + wantPath string + }{ + { + name: "inline service properties", + wantPath: "container", + }, + { + name: "legacy config properties", + legacy: true, + wantPath: "config.container", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "name": "my-chat-agent", + "customField": "preserved", + "container": map[string]any{ + "resources": map[string]any{ + "cpu": "1", + }, + }, + }) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "agent", + Host: AiAgentHost, + Image: "myregistry.azurecr.io/my-agent:${MY_TAG}", + } + if tt.legacy { + svc.Config = props + } else { + svc.AdditionalProperties = props + } + + server := &containerSettingsProjectServer{} + client := newProjectRecorderClient(t, server) + + require.NoError(t, populateContainerSettings(t.Context(), client, svc)) + + server.mu.Lock() + defer server.mu.Unlock() + + require.Zero(t, server.addServiceCalls, + "full service replacement would drop fields that are not modeled by the extension") + require.Len(t, server.setServiceRequests, 1) + + req := server.setServiceRequests[0] + require.Equal(t, "agent", req.ServiceName) + require.Equal(t, tt.wantPath, req.Path) + require.Equal(t, map[string]any{ + "resources": map[string]any{ + "cpu": "1", + "memory": project.DefaultMemory, + }, + }, req.Value.AsInterface()) + + require.Equal(t, "myregistry.azurecr.io/my-agent:${MY_TAG}", svc.Image) + require.Equal(t, "preserved", + project.ServiceConfigProps(svc).GetFields()["customField"].GetStringValue()) + }) + } +} + // TestPostdeployHandler_NonHostedAgent_NoOp verifies postdeployHandler returns nil // without any RPC calls when the service is an agent but not a hosted agent (no agent.yaml // with kind: hostedAgent). With service-level event handlers, the core filters by host type, From e28d1ac93cf3457b75dab1d29ea2d131c232fbcf Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 20 Jul 2026 18:01:37 -0400 Subject: [PATCH 2/5] refactor(ai-agents): centralize container persistence target Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fed9e97b-e79b-4889-ac76-0d9a428599cd --- .../azure.ai.agents/internal/cmd/listen.go | 18 ++----- .../internal/project/agent_definition.go | 17 ++++-- .../internal/project/agent_definition_test.go | 53 +++++++++++++++++++ 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 9cb681e29e5..c2605cf2f10 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -760,24 +760,16 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, result.Cpu = project.DefaultCpu } - containerPath := "container" - if props := svc.GetAdditionalProperties(); props == nil || len(props.GetFields()) == 0 { - if cfg := svc.GetConfig(); cfg != nil && len(cfg.GetFields()) > 0 { - containerPath = "config.container" - } - } - // Persist the resolved container settings back onto the service's inline // properties, preserving the agent definition and other config keys. - if err := project.SetAgentContainerSettings(svc, &project.ContainerSettings{Resources: result}); err != nil { + containerPath, containerValue, err := project.SetAgentContainerSettings( + svc, + &project.ContainerSettings{Resources: result}, + ) + if err != nil { return fmt.Errorf("failed to update agent container settings: %w", err) } - containerValue := project.ServiceConfigProps(svc).GetFields()["container"] - if containerValue == nil { - return errors.New("failed to read updated agent container settings") - } - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ ServiceName: svc.GetName(), Path: containerPath, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 0d97d24c693..2afaee1a8a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -284,14 +284,20 @@ func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { // agent service's inline properties, preserving every other key (the agent // definition and the rest of the deploy/provision config). It mutates whichever // shape the service uses (the unified AdditionalProperties, or — for older -// projects — the config-nested struct). -func SetAgentContainerSettings(svc *azdext.ServiceConfig, container *ContainerSettings) error { +// projects — the config-nested struct). The returned path and value identify +// the exact mutation for callers that need to persist it through the azd host. +func SetAgentContainerSettings( + svc *azdext.ServiceConfig, + container *ContainerSettings, +) (string, *structpb.Value, error) { legacy := false + containerPath := "container" props := svc.GetAdditionalProperties() if props == nil || len(props.GetFields()) == 0 { if cfg := svc.GetConfig(); cfg != nil && len(cfg.GetFields()) > 0 { props = cfg legacy = true + containerPath = "config.container" } else { props = &structpb.Struct{} } @@ -302,16 +308,17 @@ func SetAgentContainerSettings(svc *azdext.ServiceConfig, container *ContainerSe containerStruct, err := MarshalStruct(container) if err != nil { - return fmt.Errorf("marshaling container settings: %w", err) + return "", nil, fmt.Errorf("marshaling container settings: %w", err) } - props.Fields["container"] = structpb.NewStructValue(containerStruct) + containerValue := structpb.NewStructValue(containerStruct) + props.Fields["container"] = containerValue if legacy { svc.Config = props } else { svc.AdditionalProperties = props } - return nil + return containerPath, containerValue, nil } // agentDefinitionFromStruct builds the ContainerAgent from an inline/config diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 821f8ad7e18..84116c250d7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -12,6 +12,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // sampleContainerAgent returns a hosted ContainerAgent with the fields that the @@ -119,6 +120,58 @@ func TestAgentDefinitionFromService_NoDefinition(t *testing.T) { require.False(t, found) } +func TestSetAgentContainerSettings_ReturnsPersistenceTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + legacy bool + wantPath string + }{ + { + name: "inline service properties", + wantPath: "container", + }, + { + name: "legacy config properties", + legacy: true, + wantPath: "config.container", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{"customField": "preserved"}) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{} + if tt.legacy { + svc.Config = props + } else { + svc.AdditionalProperties = props + } + + path, value, err := SetAgentContainerSettings(svc, &ContainerSettings{ + Resources: &ResourceSettings{Cpu: "1", Memory: "2Gi"}, + }) + require.NoError(t, err) + require.Equal(t, tt.wantPath, path) + require.Equal(t, map[string]any{ + "resources": map[string]any{ + "cpu": "1", + "memory": "2Gi", + }, + }, value.AsInterface()) + + storedProps := ServiceConfigProps(svc) + require.Equal(t, "preserved", storedProps.GetFields()["customField"].GetStringValue()) + require.Same(t, value, storedProps.GetFields()["container"]) + }) + } +} + // TestAgentDefinition_ImageRidesOnCoreServiceField verifies the prebuilt image // maps onto the core ServiceConfig.Image field (which core binds and round-trips) // rather than the inline property bag, where core would strip it on reload. From b853f6b9e6047f955be9951fb65900b85d496228 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:53:30 +0000 Subject: [PATCH 3/5] fix: serialize service config persistence Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../internal/grpcserver/project_service.go | 36 ++++++++- .../grpcserver/project_service_test.go | 81 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/cli/azd/internal/grpcserver/project_service.go b/cli/azd/internal/grpcserver/project_service.go index 58f1fc704d0..1421db208af 100644 --- a/cli/azd/internal/grpcserver/project_service.go +++ b/cli/azd/internal/grpcserver/project_service.go @@ -7,9 +7,11 @@ import ( "context" "fmt" "log" + "sync" "github.com/azure/azure-dev/cli/azd/internal/mapper" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/ext" @@ -31,6 +33,7 @@ type projectService struct { importManager *project.ImportManager lazyProjectConfig *lazy.Lazy[*project.ProjectConfig] ghCli *github.Cli + configMutationMu sync.Mutex } // NewProjectService creates a new project service instance with lazy-loaded dependencies. @@ -172,6 +175,9 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR return nil, status.Error(codes.InvalidArgument, "service name cannot be empty") } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err @@ -376,6 +382,9 @@ func (s *projectService) SetConfigSection( return nil, status.Error(codes.InvalidArgument, "path cannot be empty") } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err @@ -424,6 +433,9 @@ func (s *projectService) SetConfigValue( return nil, status.Error(codes.InvalidArgument, "path cannot be empty") } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err @@ -472,6 +484,9 @@ func (s *projectService) UnsetConfig( return nil, status.Error(codes.InvalidArgument, "path cannot be empty") } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err @@ -646,6 +661,9 @@ func (s *projectService) SetServiceConfigSection( return nil, status.Error(codes.InvalidArgument, "service name cannot be empty") } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err @@ -710,6 +728,9 @@ func (s *projectService) SetServiceConfigValue( return nil, status.Error(codes.InvalidArgument, "path cannot be empty") } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err @@ -726,12 +747,18 @@ func (s *projectService) SetServiceConfigValue( return nil, err } - // Construct path to service config value: "services.." - servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path) + services, ok := cfg.Raw()["services"].(map[string]any) + if !ok { + return nil, fmt.Errorf("services configuration not found") + } + serviceConfig, ok := services[req.ServiceName].(map[string]any) + if !ok { + return nil, fmt.Errorf("service configuration for '%s' not found", req.ServiceName) + } // Convert protobuf Value to interface{} value := req.Value.AsInterface() - if err := cfg.Set(servicePath, value); err != nil { + if err := config.NewConfig(serviceConfig).Set(req.Path, value); err != nil { return nil, fmt.Errorf("failed to set service config value: %w", err) } @@ -771,6 +798,9 @@ func (s *projectService) UnsetServiceConfig( return nil, status.Error(codes.InvalidArgument, "path cannot be empty") } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err diff --git a/cli/azd/internal/grpcserver/project_service_test.go b/cli/azd/internal/grpcserver/project_service_test.go index 1e9e500b2f7..a580da72e23 100644 --- a/cli/azd/internal/grpcserver/project_service_test.go +++ b/cli/azd/internal/grpcserver/project_service_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "testing" @@ -2649,6 +2650,86 @@ func TestProjectService_SetServiceConfigValue_HappyPath(t *testing.T) { require.NoError(t, err) } +func TestProjectService_SetServiceConfigValue_DottedServiceName(t *testing.T) { + t.Parallel() + svc := newProjectServiceWithYaml(t, `name: test-project +services: + my.agent: + host: appservice +`) + + _, err := svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{ + ServiceName: "my.agent", + Path: "container.resources.cpu", + Value: structpb.NewStringValue("2"), + }) + require.NoError(t, err) + + projectService := svc.(*projectService) + azdContext, err := projectService.lazyAzdContext.GetValue() + require.NoError(t, err) + cfg, err := project.LoadConfig(t.Context(), azdContext.ProjectPath()) + require.NoError(t, err) + services, found := cfg.GetMap("services") + require.True(t, found) + serviceConfig, ok := services["my.agent"].(map[string]any) + require.True(t, ok) + value, found := config.NewConfig(serviceConfig).Get("container.resources.cpu") + require.True(t, found) + require.Equal(t, "2", value) + require.NotContains(t, services, "my") +} + +func TestProjectService_SetServiceConfigValue_Concurrent(t *testing.T) { + t.Parallel() + svc := newProjectServiceWithYaml(t, yamlWithService) + paths := []string{ + "custom.one", + "custom.two", + "custom.three", + "custom.four", + "custom.five", + "custom.six", + "custom.seven", + "custom.eight", + "custom.nine", + "custom.ten", + } + start := make(chan struct{}) + errs := make(chan error, len(paths)) + var wg sync.WaitGroup + + for _, path := range paths { + wg.Go(func() { + <-start + _, err := svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{ + ServiceName: "api", + Path: path, + Value: structpb.NewStringValue(path), + }) + errs <- err + }) + } + close(start) + wg.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } + + projectService := svc.(*projectService) + azdContext, err := projectService.lazyAzdContext.GetValue() + require.NoError(t, err) + cfg, err := project.LoadConfig(t.Context(), azdContext.ProjectPath()) + require.NoError(t, err) + for _, path := range paths { + value, found := cfg.Get("services.api." + path) + require.True(t, found) + require.Equal(t, path, value) + } +} + func TestProjectService_UnsetServiceConfig_HappyPath(t *testing.T) { t.Parallel() svc := newProjectServiceWithYaml(t, yamlWithService) From 198a1ff0e325794ac796dac4a9923680519c6aff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:07:58 +0000 Subject: [PATCH 4/5] fix(ai-agents): address container persistence review Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/listen.go | 14 +++++----- .../internal/cmd/listen_test.go | 4 ++- .../internal/project/agent_definition.go | 17 +++++------- .../internal/project/agent_definition_test.go | 26 +++++++++++++++---- 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 438ea0ee124..a7416eccc92 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -821,12 +821,14 @@ func prepareContainerSettings( return fmt.Errorf("failed to update agent container settings: %w", err) } - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ - ServiceName: svc.GetName(), - Path: containerPath, - Value: containerValue, - }); err != nil { - return fmt.Errorf("persisting agent container settings: %w", err) + if !hasRootFileRef { + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: svc.GetName(), + Path: containerPath, + Value: containerValue, + }); err != nil { + return fmt.Errorf("persisting agent container settings: %w", err) + } } return nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go index a992986bb88..b047bbf233d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go @@ -263,7 +263,8 @@ func TestPrepareContainerSettings_DoesNotPersistResolvedFileRef( RelativePath: "src/echo", AdditionalProperties: props, } - client := newProjectRecorderClient(t, &containerSettingsProjectServer{}) + server := &containerSettingsProjectServer{} + client := newProjectRecorderClient(t, server) err = prepareContainerSettings(t.Context(), client, svc, root) @@ -275,6 +276,7 @@ func TestPrepareContainerSettings_DoesNotPersistResolvedFileRef( require.NotNil(t, cfg.Container.Resources) require.Equal(t, "2", cfg.Container.Resources.Cpu) require.Equal(t, "4Gi", cfg.Container.Resources.Memory) + require.Empty(t, server.setServiceRequests) } func TestPrepareContainerSettings_PreservesNestedFileRef(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 0d17a3208d7..87042e77276 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -555,17 +555,14 @@ func SetAgentContainerSettings( svc *azdext.ServiceConfig, container *ContainerSettings, ) (string, *structpb.Value, error) { - legacy := false + props := ServiceConfigProps(svc) + legacy := props != nil && props == svc.GetConfig() containerPath := "container" - props := svc.GetAdditionalProperties() - if props == nil || len(props.GetFields()) == 0 { - if cfg := svc.GetConfig(); cfg != nil && len(cfg.GetFields()) > 0 { - props = cfg - legacy = true - containerPath = "config.container" - } else { - props = &structpb.Struct{} - } + if legacy { + containerPath = "config.container" + } + if props == nil { + props = &structpb.Struct{} } if props.Fields == nil { props.Fields = map[string]*structpb.Value{} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index ff848b24930..d1696d52ca3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -161,9 +161,10 @@ func TestSetAgentContainerSettings_ReturnsPersistenceTarget(t *testing.T) { t.Parallel() tests := []struct { - name string - legacy bool - wantPath string + name string + legacy bool + unrelatedInline bool + wantPath string }{ { name: "inline service properties", @@ -174,21 +175,36 @@ func TestSetAgentContainerSettings_ReturnsPersistenceTarget(t *testing.T) { legacy: true, wantPath: "config.container", }, + { + name: "legacy config properties with unrelated inline properties", + legacy: true, + unrelatedInline: true, + wantPath: "config.container", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - props, err := structpb.NewStruct(map[string]any{"customField": "preserved"}) + props, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "customField": "preserved", + }) require.NoError(t, err) - svc := &azdext.ServiceConfig{} + svc := &azdext.ServiceConfig{Host: "azure.ai.agent"} if tt.legacy { svc.Config = props } else { svc.AdditionalProperties = props } + if tt.unrelatedInline { + svc.AdditionalProperties, err = structpb.NewStruct(map[string]any{ + "resumeSessionOnDeploy": true, + }) + require.NoError(t, err) + } path, value, err := SetAgentContainerSettings(svc, &ContainerSettings{ Resources: &ResourceSettings{Cpu: "1", Memory: "2Gi"}, From d08d5b8db9cb50547b570eca2353b23825306e57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:25:46 +0000 Subject: [PATCH 5/5] test: preserve agent hooks through config updates Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../grpcserver/project_service_test.go | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/cli/azd/internal/grpcserver/project_service_test.go b/cli/azd/internal/grpcserver/project_service_test.go index a580da72e23..1c195fcfa90 100644 --- a/cli/azd/internal/grpcserver/project_service_test.go +++ b/cli/azd/internal/grpcserver/project_service_test.go @@ -2650,6 +2650,63 @@ func TestProjectService_SetServiceConfigValue_HappyPath(t *testing.T) { require.NoError(t, err) } +func TestProjectService_SetServiceConfigValue_PreservesServiceHooksAndTemplatedImage(t *testing.T) { + t.Parallel() + svc := newProjectServiceWithYaml(t, `name: my-app +services: + agent: + project: src/my-agent + host: azure.ai.agent + language: python + image: myregistry.azurecr.io/my-agent:${MY_TAG} + kind: hosted + name: my-chat-agent + protocols: + - protocol: responses + version: 2.0.0 + startupCommand: python init.py + hooks: + predeploy: + shell: sh + run: ./hooks/agent-predeploy.sh + interactive: true + postdeploy: + shell: sh + run: ./hooks/agent-postdeploy.sh + interactive: true +`) + + _, err := svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{ + ServiceName: "agent", + Path: "container.resources.cpu", + Value: structpb.NewStringValue("2"), + }) + require.NoError(t, err) + + projectService := svc.(*projectService) + azdContext, err := projectService.lazyAzdContext.GetValue() + require.NoError(t, err) + saved, err := project.LoadConfig(t.Context(), azdContext.ProjectPath()) + require.NoError(t, err) + services, found := saved.GetMap("services") + require.True(t, found) + serviceConfig, ok := services["agent"].(map[string]any) + require.True(t, ok) + require.Equal(t, "myregistry.azurecr.io/my-agent:${MY_TAG}", serviceConfig["image"]) + require.Equal(t, map[string]any{ + "predeploy": map[string]any{ + "shell": "sh", + "run": "./hooks/agent-predeploy.sh", + "interactive": true, + }, + "postdeploy": map[string]any{ + "shell": "sh", + "run": "./hooks/agent-postdeploy.sh", + "interactive": true, + }, + }, serviceConfig["hooks"]) +} + func TestProjectService_SetServiceConfigValue_DottedServiceName(t *testing.T) { t.Parallel() svc := newProjectServiceWithYaml(t, `name: test-project