Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -762,15 +762,20 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient,

// 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)
}

// Need to add the service config back to the project for use further down the pipeline
req := &azdext.AddServiceRequest{Service: svc}

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
Expand Down
105 changes: 105 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cmd

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading