From b82542b00e8e03a6144d33fd6568589b12c899c1 Mon Sep 17 00:00:00 2001 From: Hui Miao Date: Wed, 21 Oct 2026 10:41:56 +0800 Subject: [PATCH 1/7] feat: add shared Foundry extension feature access Add a self-contained featureflags package for first-party Foundry extensions. A feature is enabled when any layer opts in: user config alpha. or alpha.all set to on, the process env var, the selected azd environment .env, or any azure.yaml services[].env map. It reads only through existing azd gRPC services and needs no core change. --- cli/azd/extensions/featureflags/README.md | 56 ++ .../extensions/featureflags/featureflags.go | 162 ++++ .../featureflags/featureflags_test.go | 709 ++++++++++++++++++ 3 files changed, 927 insertions(+) create mode 100644 cli/azd/extensions/featureflags/README.md create mode 100644 cli/azd/extensions/featureflags/featureflags.go create mode 100644 cli/azd/extensions/featureflags/featureflags_test.go diff --git a/cli/azd/extensions/featureflags/README.md b/cli/azd/extensions/featureflags/README.md new file mode 100644 index 00000000000..05d6fdd316a --- /dev/null +++ b/cli/azd/extensions/featureflags/README.md @@ -0,0 +1,56 @@ +# Shared Foundry feature access + + + +`featureflags` is the common opt-in reader for first-party Foundry extensions. It is self-contained and needs no azd core change. It reads only through existing azd gRPC services (`UserConfig`, `Environment`, `Project`). + +It belongs to the root `cli/azd` Go module, like the shared `extensions/telemetry` package. An extension module imports it as `github.com/azure/azure-dev/cli/azd/extensions/featureflags` once its `github.com/azure/azure-dev/cli/azd` requirement points at a version that contains this package. + +This package adds access only. No existing command or feature uses it yet. + +## API + +```go +var hosted = featureflags.Feature{ + ID: "agents.hosted", + EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_HOSTED", +} + +enabled, err := featureflags.IsEnabled( + extCtx.Context(), + azdClient, + hosted, + extCtx.Environment, +) +``` + +Each extension declares its own `Feature`. There is no shared registry and no change to the core alpha feature list. + +Pass the parsed `azdext.ExtensionContext.Environment` rather than discovering the default yourself. A nonempty name is forwarded to `Environment.GetValues` unchanged, so `-e/--environment` takes precedence over the project default. An invalid explicit selection is an error. An empty name uses the default environment if there is one. + +## Independent enablement sources + +A feature is enabled if **any** of these sources opts in: + +| Source | Read | Enabling value | +| --- | --- | --- | +| Global user config | `UserConfig.Get("alpha")` (`azd config set`) | Case-insensitive `on` at `alpha.` or `alpha.all`, same as azd core | +| Process environment | Only `EnvVar` | A true value accepted by `strconv.ParseBool` | +| Selected azd environment | `EnvVar` in its `.env` values from `Environment.GetValues` (`azd env set`) | A true value accepted by `strconv.ParseBool` | +| Project service environment | `EnvVar` in each `services[].env` map from `Project.Get` | A true value accepted by `strconv.ParseBool` | + +Canonical environment names are: + +- `AZD_FOUNDRY_EXT_ENABLE_` for shared capabilities. +- `AZD_FOUNDRY_EXT_ENABLE_AGENTS_` for agent-specific capabilities. +- `AZD_FOUNDRY_EXT_ENABLE_MODELS_` for model-specific capabilities. + +All service hosts count. Service maps stay separate, so a true value in any service wins regardless of other services' values or order. + +False, empty, missing, and unrecognized values contribute no signal and never override another source's opt-in. Non-string config values are also non-enabling. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default. + +## Scope and errors + +Each call loads fresh snapshots and discards them. There is no global cache, process environment mutation, output, authentication call, or configuration write. + +Outside a project, only process environment and user config apply. In a project without a default environment, process, config, and service opt-ins still apply. Only the host's exact no-project and no-default-environment errors count as absence, and only when no environment name was supplied. Other project, dotenv, config, decoding, and transport errors are returned with context, even when another source opts in. diff --git a/cli/azd/extensions/featureflags/featureflags.go b/cli/azd/extensions/featureflags/featureflags.go new file mode 100644 index 00000000000..032d702d00d --- /dev/null +++ b/cli/azd/extensions/featureflags/featureflags.go @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// cspell:ignore featureflags + +// Package featureflags resolves shared Foundry extension opt-ins. +package featureflags + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Feature declares one extension opt-in. +type Feature struct { + // ID is the user config key under alpha, for example "agents.hosted". + ID string + // EnvVar is the only environment key read, for example + // "AZD_FOUNDRY_EXT_ENABLE_AGENTS_HOSTED". + EnvVar string +} + +const ( + alphaKey = "alpha" + allFeatureID = "all" + enabledValue = "on" +) + +// Host errors for an absent optional scope. They match +// azdcontext.ErrNoProject and environment.ErrDefaultEnvironmentNotFound +// without importing those packages and their dependencies. +var ( + errNoProject = errors.New("no project exists; to create a new project, run `azd init`") + errDefaultEnvironmentNotFound = errors.New("default environment not found") +) + +type azdClient interface { + Project() azdext.ProjectServiceClient + Environment() azdext.EnvironmentServiceClient + UserConfig() azdext.UserConfigServiceClient +} + +// IsEnabled resolves feature for one invocation. +// Pass ExtensionContext.Environment to honor -e/--environment. +// An empty environmentName uses the project default, if one exists. +// Loading failures are returned even when another source enables it. +func IsEnabled( + ctx context.Context, + client *azdext.AzdClient, + feature Feature, + environmentName string, +) (bool, error) { + if client == nil { + return false, errors.New("featureflags.IsEnabled: client must not be nil") + } + if feature.ID == "" || feature.EnvVar == "" { + return false, errors.New("featureflags.IsEnabled: feature ID and EnvVar must not be empty") + } + + return isEnabled(ctx, client, feature, environmentName) +} + +func isEnabled( + ctx context.Context, + client azdClient, + feature Feature, + environmentName string, +) (bool, error) { + enabled := envEnabled(os.Getenv(feature.EnvVar)) + + configResponse, err := client.UserConfig().Get(ctx, &azdext.GetUserConfigRequest{Path: alphaKey}) + if err != nil { + return false, fmt.Errorf("reading user config for feature %q: %w", feature.ID, err) + } + if configResponse == nil { + return false, fmt.Errorf("reading user config for feature %q: missing response", feature.ID) + } + if configResponse.Found { + var alphaValues any + if err := json.Unmarshal(configResponse.Value, &alphaValues); err != nil { + return false, fmt.Errorf("decoding alpha user config for feature %q: %w", feature.ID, err) + } + enabled = configEnabled(alphaValues, feature.ID) || enabled + } + + projectResponse, err := client.Project().Get(ctx, &azdext.EmptyRequest{}) + switch { + case err != nil && environmentName == "" && isAbsentScope(err, errNoProject): + // Without a project, neither local scope is available. + return enabled, nil + case err != nil: + return false, fmt.Errorf("loading project for feature %q: %w", feature.ID, err) + case projectResponse.GetProject() == nil: + return false, fmt.Errorf("loading project for feature %q: missing project response", feature.ID) + } + + valuesResponse, err := client.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: environmentName, + }) + switch { + case err != nil && environmentName == "" && isAbsentScope(err, errDefaultEnvironmentNotFound): + // A project need not have a default environment. + case err != nil: + return false, fmt.Errorf("reading azd environment %q for feature %q: %w", environmentName, feature.ID, err) + case valuesResponse == nil: + return false, fmt.Errorf("reading azd environment for feature %q: missing response", feature.ID) + default: + for _, value := range valuesResponse.KeyValues { + if value.GetKey() == feature.EnvVar && envEnabled(value.GetValue()) { + enabled = true + } + } + } + + // Each service map counts on its own, so one false never hides another true. + for _, service := range projectResponse.Project.Services { + if envEnabled(service.GetEnvironment()[feature.EnvVar]) { + enabled = true + } + } + + return enabled, nil +} + +// envEnabled reports whether value is a true strconv.ParseBool value. +// False, empty, and unparsable values contribute no signal. +func envEnabled(value string) bool { + enabled, err := strconv.ParseBool(value) + return err == nil && enabled +} + +// configEnabled matches azd core: "on" (any case) at alpha.all or alpha.. +// Other values, including non-string values, contribute no signal. +func configEnabled(alphaValues any, id string) bool { + userConfig := config.NewConfig(map[string]any{alphaKey: alphaValues}) + for _, key := range []string{allFeatureID, id} { + if value, ok := userConfig.GetString(alphaKey + "." + key); ok && strings.EqualFold(value, enabledValue) { + return true + } + } + + return false +} + +func isAbsentScope(err error, sentinel error) bool { + // The host sends these sentinels as Unknown, without details. + // Match the exact sentinel, never a broad transport status. + // A plain error converts to Unknown with its own message. + st, _ := status.FromError(err) + return st.Code() == codes.Unknown && + st.Message() == sentinel.Error() && len(st.Proto().Details) == 0 +} diff --git a/cli/azd/extensions/featureflags/featureflags_test.go b/cli/azd/extensions/featureflags/featureflags_test.go new file mode 100644 index 00000000000..fdc5012971e --- /dev/null +++ b/cli/azd/extensions/featureflags/featureflags_test.go @@ -0,0 +1,709 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package featureflags + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "maps" + "os" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var fixture = Feature{ + ID: "foundry.fixture", + EnvVar: "AZD_FOUNDRY_EXT_ENABLE_TEST_FIXTURE", +} + +func TestIsEnabledValidation(t *testing.T) { + tests := []struct { + name string + client *azdext.AzdClient + feature Feature + want string + }{ + {"nil client", nil, fixture, "client must not be nil"}, + {"missing ID", &azdext.AzdClient{}, Feature{EnvVar: fixture.EnvVar}, "must not be empty"}, + {"missing EnvVar", &azdext.AzdClient{}, Feature{ID: fixture.ID}, "must not be empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enabled, err := IsEnabled(t.Context(), tt.client, tt.feature, "") + require.False(t, enabled) + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestFourSourceCombinations(t *testing.T) { + for mask := range 16 { + t.Run(fmt.Sprintf("%04b", mask), func(t *testing.T) { + t.Setenv(fixture.EnvVar, fmt.Sprint(mask&1 != 0)) + configValue := "off" + if mask&4 != 0 { + configValue = "on" + } + client := newMockClient(t) + client.expectSources( + alphaResponse(t, configValue, "off"), + projectWithServices(service("containerapp", fmt.Sprint(mask&8 != 0))), + map[string]string{fixture.EnvVar: fmt.Sprint(mask&2 != 0)}, + "selected", + ) + + enabled, err := isEnabled(t.Context(), client, fixture, "selected") + + require.NoError(t, err) + require.Equal(t, mask != 0, enabled) + }) + } +} + +func TestEveryServiceCounts(t *testing.T) { + tests := []struct { + name string + services []*azdext.ServiceConfig + want bool + }{ + {"no services", nil, false}, + {"agent service", []*azdext.ServiceConfig{service("azure.ai.agent", "true")}, true}, + {"project service", []*azdext.ServiceConfig{service("azure.ai.project", "true")}, true}, + {"non-Foundry service", []*azdext.ServiceConfig{service("appservice", "true")}, true}, + { + "multiple project services", + []*azdext.ServiceConfig{ + service("azure.ai.project", "false"), + service("azure.ai.project", "true"), + }, + true, + }, + { + "unrelated service enables multiple projects", + []*azdext.ServiceConfig{ + service("azure.ai.project", "false"), + service("azure.ai.project", "false"), + service("containerapp", "true"), + }, + true, + }, + {"empty service", []*azdext.ServiceConfig{{}}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + client := newMockClient(t) + client.expectSources( + alphaResponse(t, "off", "off"), + projectWithServices(tt.services...), + nil, + "", + ) + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.Equal(t, tt.want, enabled) + }) + } +} + +func TestConflictingServiceOrder(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + for enabledIndex := range 2 { + t.Run(fmt.Sprint(enabledIndex), func(t *testing.T) { + services := []*azdext.ServiceConfig{ + service("azure.ai.project", "false"), + service("containerapp", "false"), + } + services[enabledIndex].Environment[fixture.EnvVar] = "true" + client := newMockClient(t) + + for range 32 { + client.expectSources(alphaResponse(t, "off", "off"), projectWithServices(services...), nil, "") + enabled, err := isEnabled(t.Context(), client, fixture, "") + require.NoError(t, err) + require.True(t, enabled) + } + }) + } +} + +func TestEnvironmentValues(t *testing.T) { + values := []struct { + value string + want bool + }{ + {"1", true}, {"t", true}, {"T", true}, + {"true", true}, {"TRUE", true}, {"True", true}, + {"0", false}, {"f", false}, {"F", false}, + {"false", false}, {"FALSE", false}, {"False", false}, + {"", false}, {"off", false}, {"on", false}, + {"yes", false}, {" true ", false}, {"invalid", false}, + } + for _, source := range []string{"process", "dotenv", "service"} { + for _, tt := range values { + t.Run(source+"/"+tt.value, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + dotenv := map[string]string{} + svc := service("containerapp", "false") + switch source { + case "process": + t.Setenv(fixture.EnvVar, tt.value) + case "dotenv": + dotenv[fixture.EnvVar] = tt.value + case "service": + svc.Environment[fixture.EnvVar] = tt.value + } + client := newMockClient(t) + client.expectSources(alphaResponse(t, "off", "off"), projectWithServices(svc), dotenv, "") + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.Equal(t, tt.want, enabled) + }) + } + } +} + +func TestConfigValues(t *testing.T) { + values := []struct { + name string + value any + want bool + }{ + {"on", "on", true}, + {"upper", "ON", true}, + {"mixed case", "On", true}, + {"off", "off", false}, + {"empty", "", false}, + {"true string", "true", false}, + {"whitespace", " on ", false}, + {"unknown string", "other", false}, + {"boolean", true, false}, + {"number", 1, false}, + {"null", nil, false}, + {"object", map[string]any{"enabled": "on"}, false}, + {"array", []string{"on"}, false}, + } + for _, path := range []string{"feature", "all"} { + for _, tt := range values { + t.Run(path+"/"+tt.name, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + var featureValue, allValue any = "off", "off" + if path == "feature" { + featureValue = tt.value + } else { + allValue = tt.value + } + client := newMockClient(t) + client.expectSources( + alphaResponse(t, featureValue, allValue), + projectWithServices(), + nil, + "", + ) + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.Equal(t, tt.want, enabled) + }) + } + } +} + +func TestNonEnablingConfigDoesNotOverride(t *testing.T) { + for _, response := range []*azdext.GetUserConfigResponse{ + {}, + {Found: true, Value: []byte(`null`)}, + {Found: true, Value: []byte(`false`)}, + {Found: true, Value: []byte(`"off"`)}, + {Found: true, Value: []byte(`["on"]`)}, + alphaResponse(t, false, "off"), + alphaResponse(t, "off", false), + alphaResponse(t, "off", "ON"), + alphaResponse(t, "ON", "off"), + } { + t.Run(string(response.Value), func(t *testing.T) { + t.Setenv(fixture.EnvVar, "true") + client := newMockClient(t) + client.expectSources(response, projectWithServices(), nil, "") + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.True(t, enabled) + }) + } +} + +func TestNonObjectAlphaSectionIsDisabled(t *testing.T) { + for _, value := range []string{`null`, `false`, `true`, `1`, `"on"`, `["on"]`} { + t.Run(value, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + client := newMockClient(t) + client.expectSources( + &azdext.GetUserConfigResponse{Found: true, Value: []byte(value)}, + projectWithServices(), + nil, + "", + ) + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.False(t, enabled) + }) + } +} + +func TestOnlyCanonicalEnvironmentKey(t *testing.T) { + t.Setenv(fixture.EnvVar, "") + aliases := map[string]string{ + "AZD_FOUNDRY_EXT_ENABLE_ALL": "true", + "AZD_ALPHA_ENABLE_FOUNDRY_FIXTURE": "true", + "AZD_ALPHA_ENABLE_FOUNDRY.FIXTURE": "true", + "AZD_FOUNDRY_ENABLE_TEST_FIXTURE": "true", + "AZD_AGENT_RESUME_SESSION_ON_DEPLOY": "true", + "AZD_FOUNDRY_EXT_ENABLE_AGENTS_OTHER": "true", + "AZD_FOUNDRY_EXT_ENABLE_MODELS_OTHER": "true", + "AZD_FOUNDRY_EXT_ENABLE_TEST_FIXTURE_": "true", + } + for name, value := range aliases { + t.Setenv(name, value) + } + client := newMockClient(t) + client.expectSources( + alphaResponse(t, "off", "off"), + projectWithServices(&azdext.ServiceConfig{Host: "containerapp", Environment: maps.Clone(aliases)}), + aliases, + "", + ) + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.False(t, enabled) +} + +func TestEnvironmentSelection(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + t.Setenv("AZD_ENVIRONMENT", "ambient-default") + for _, name := range []string{"", "explicit-selection"} { + t.Run(name, func(t *testing.T) { + client := newMockClient(t) + client.expectSources( + alphaResponse(t, "off", "off"), + projectWithServices(), + map[string]string{fixture.EnvVar: fmt.Sprint(name != "")}, + name, + ) + + enabled, err := isEnabled(t.Context(), client, fixture, name) + + require.NoError(t, err) + require.Equal(t, name != "", enabled) + }) + } +} + +func TestAbsentScopeSentinelsMatchHost(t *testing.T) { + require.Equal(t, azdcontext.ErrNoProject.Error(), errNoProject.Error()) + require.Equal(t, environment.ErrDefaultEnvironmentNotFound.Error(), errDefaultEnvironmentNotFound.Error()) +} + +func TestOptionalScopes(t *testing.T) { + tests := []struct { + name string + projectErr error + dotenvErr error + }{ + {"no project", azdcontext.ErrNoProject, nil}, + {"no project over gRPC", status.Error(codes.Unknown, azdcontext.ErrNoProject.Error()), nil}, + {"no default environment", nil, environment.ErrDefaultEnvironmentNotFound}, + { + "no default environment over gRPC", + nil, + status.Error(codes.Unknown, environment.ErrDefaultEnvironmentNotFound.Error()), + }, + } + for _, tt := range tests { + for _, positive := range []bool{false, true} { + t.Run(tt.name+"/"+fmt.Sprint(positive), func(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + client := newMockClient(t) + value := "off" + if positive && tt.projectErr != nil { + value = "on" + } + client.expectConfig(alphaResponse(t, value, "off"), nil) + if tt.projectErr != nil { + client.expectProject(nil, tt.projectErr) + } else { + client.expectProject(&azdext.GetProjectResponse{ + Project: projectWithServices(service("appservice", fmt.Sprint(positive))), + }, nil) + client.expectDotenv("", nil, tt.dotenvErr) + } + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.Equal(t, positive, enabled) + }) + } + } +} + +func TestProcessOptInWithoutProject(t *testing.T) { + t.Setenv(fixture.EnvVar, "true") + client := newMockClient(t) + client.expectConfig(&azdext.GetUserConfigResponse{}, nil) + client.expectProject(nil, status.Error(codes.Unknown, azdcontext.ErrNoProject.Error())) + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.True(t, enabled) +} + +func TestSourceErrorsAreReturned(t *testing.T) { + detailedAbsence, err := status.New(codes.Unknown, azdcontext.ErrNoProject.Error()).WithDetails( + &errdetails.ErrorInfo{Reason: "load_failed"}, + ) + require.NoError(t, err) + tests := []struct { + name string + stage string + cause error + environmentName string + }{ + {"config transport", "config", status.Error(codes.Unavailable, "connection lost"), ""}, + {"config read", "config", &fs.PathError{Op: "read", Path: "config.json", Err: fs.ErrPermission}, ""}, + {"project transport", "project", status.Error(codes.Unavailable, "connection lost"), ""}, + {"project decoding", "project", errors.New("decoding azure.yaml"), ""}, + {"project read", "project", &fs.PathError{Op: "read", Path: "azure.yaml", Err: fs.ErrPermission}, ""}, + {"project not found status", "project", status.Error(codes.NotFound, "project file disappeared"), ""}, + { + "project similar message", + "project", + status.Error(codes.Unknown, "loading project: "+azdcontext.ErrNoProject.Error()), + "", + }, + {"project status has details", "project", detailedAbsence.Err(), ""}, + {"explicit environment without project", "project", azdcontext.ErrNoProject, "selected"}, + {"dotenv transport", "dotenv", status.Error(codes.Unavailable, "connection lost"), ""}, + {"dotenv decoding", "dotenv", errors.New("decoding dotenv"), ""}, + {"dotenv read", "dotenv", &fs.PathError{Op: "read", Path: ".env", Err: fs.ErrPermission}, ""}, + {"invalid default environment", "dotenv", environment.ErrNotFound, ""}, + {"invalid explicit environment", "dotenv", environment.ErrNotFound, "selected"}, + {"explicit no default sentinel", "dotenv", environment.ErrDefaultEnvironmentNotFound, "selected"}, + { + "explicit no default sentinel over gRPC", + "dotenv", + status.Error(codes.Unknown, environment.ErrDefaultEnvironmentNotFound.Error()), + "selected", + }, + { + "untyped default absence with context", + "dotenv", + status.Error(codes.Unknown, "reading state: "+environment.ErrDefaultEnvironmentNotFound.Error()), + "", + }, + {"dotenv not found status", "dotenv", status.Error(codes.NotFound, "dotenv missing"), ""}, + {"project disappeared during dotenv read", "dotenv", azdcontext.ErrNoProject, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "true") + client := newMockClient(t) + configResponse := alphaResponse(t, "on", "on") + switch tt.stage { + case "config": + client.expectConfig(nil, tt.cause) + case "project": + client.expectConfig(configResponse, nil) + client.expectProject(nil, tt.cause) + case "dotenv": + client.expectConfig(configResponse, nil) + client.expectProject(&azdext.GetProjectResponse{Project: projectWithServices()}, nil) + client.expectDotenv(tt.environmentName, nil, tt.cause) + } + + enabled, err := isEnabled(t.Context(), client, fixture, tt.environmentName) + + require.False(t, enabled) + require.ErrorIs(t, err, tt.cause) + require.ErrorContains(t, err, fixture.ID) + switch tt.stage { + case "config": + require.ErrorContains(t, err, "reading user config") + case "project": + require.ErrorContains(t, err, "loading project") + case "dotenv": + require.ErrorContains(t, err, "reading azd environment") + } + }) + } +} + +func TestMalformedResponses(t *testing.T) { + tests := []struct { + name string + stage string + value []byte + want string + }{ + {"nil config", "config", nil, "missing response"}, + {"nil project response", "project", nil, "missing project response"}, + {"nil project", "empty project", nil, "missing project response"}, + {"nil dotenv", "dotenv", nil, "missing response"}, + {"empty config JSON", "decode", nil, "decoding alpha user config"}, + {"malformed config JSON", "decode", []byte(`{"foundry":`), "decoding alpha user config"}, + { + "config JSON does not disclose values", + "decode", + []byte(`{"secret":"private-fixture-value"} trailing-data`), + "decoding alpha user config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "true") + client := newMockClient(t) + switch tt.stage { + case "config": + client.expectConfig(nil, nil) + case "decode": + client.expectConfig(&azdext.GetUserConfigResponse{Found: true, Value: tt.value}, nil) + default: + client.expectConfig(alphaResponse(t, "on", "on"), nil) + switch tt.stage { + case "project": + client.expectProject(nil, nil) + case "empty project": + client.expectProject(&azdext.GetProjectResponse{}, nil) + case "dotenv": + client.expectProject(&azdext.GetProjectResponse{Project: projectWithServices()}, nil) + client.expectDotenv("", nil, nil) + } + } + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.False(t, enabled) + require.ErrorContains(t, err, tt.want) + require.NotContains(t, err.Error(), "private-fixture-value") + if tt.stage == "decode" { + _, ok := errors.AsType[*json.SyntaxError](err) + require.True(t, ok) + } + }) + } +} + +func TestInvocationSnapshotsDoNotLeakOrMutate(t *testing.T) { + for _, source := range []string{"process", "dotenv", "config", "service"} { + t.Run(source, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + client := newMockClient(t) + for _, positive := range []bool{true, false} { + configValue := "off" + dotenv := map[string]string{fixture.EnvVar: "false", "UNRELATED": "private-fixture-value"} + svc := service("containerapp", "false") + if positive { + switch source { + case "process": + t.Setenv(fixture.EnvVar, "true") + case "dotenv": + dotenv[fixture.EnvVar] = "true" + case "config": + configValue = "on" + case "service": + svc.Environment[fixture.EnvVar] = "true" + } + } else { + t.Setenv(fixture.EnvVar, "false") + } + configResponse := alphaResponse(t, configValue, "off") + configBefore := bytes.Clone(configResponse.Value) + dotenvBefore := maps.Clone(dotenv) + serviceBefore := maps.Clone(svc.Environment) + processBefore := os.Getenv(fixture.EnvVar) + client.expectSources(configResponse, projectWithServices(svc), dotenv, "selected") + + enabled, err := isEnabled(t.Context(), client, fixture, "selected") + + require.NoError(t, err) + require.Equal(t, positive, enabled) + require.Equal(t, configBefore, configResponse.Value) + require.Equal(t, dotenvBefore, dotenv) + require.Equal(t, serviceBefore, svc.Environment) + require.Equal(t, processBefore, os.Getenv(fixture.EnvVar)) + } + }) + } +} + +type contextKey struct{} + +func TestContextIsPropagated(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + ctx := context.WithValue(t.Context(), contextKey{}, "invocation") + client := newMockClient(t) + client.On("UserConfig.Get", ctx, &azdext.GetUserConfigRequest{Path: "alpha"}). + Return(&azdext.GetUserConfigResponse{}, nil).Once() + client.On("Project.Get", ctx, &azdext.EmptyRequest{}). + Return(&azdext.GetProjectResponse{Project: projectWithServices()}, nil).Once() + client.On("Environment.GetValues", ctx, &azdext.GetEnvironmentRequest{Name: "selected"}). + Return(&azdext.KeyValueListResponse{}, nil).Once() + + enabled, err := isEnabled(ctx, client, fixture, "selected") + + require.NoError(t, err) + require.False(t, enabled) +} + +func service(host, value string) *azdext.ServiceConfig { + return &azdext.ServiceConfig{Host: host, Environment: map[string]string{fixture.EnvVar: value}} +} + +func projectWithServices(services ...*azdext.ServiceConfig) *azdext.ProjectConfig { + project := &azdext.ProjectConfig{Services: map[string]*azdext.ServiceConfig{}} + for i, svc := range services { + project.Services[fmt.Sprintf("service-%d", i)] = svc + } + return project +} + +func alphaResponse(t *testing.T, value, all any) *azdext.GetUserConfigResponse { + t.Helper() + data, err := json.Marshal(map[string]any{ + "foundry": map[string]any{"fixture": value}, + "all": all, + }) + require.NoError(t, err) + return &azdext.GetUserConfigResponse{Found: true, Value: data} +} + +type mockClient struct { + mock.Mock + t *testing.T +} + +func newMockClient(t *testing.T) *mockClient { + t.Helper() + client := &mockClient{t: t} + client.Test(t) + t.Cleanup(func() { client.AssertExpectations(t) }) + return client +} + +func (c *mockClient) Project() azdext.ProjectServiceClient { + return &mockProjectClient{client: c} +} + +func (c *mockClient) Environment() azdext.EnvironmentServiceClient { + return &mockEnvironmentClient{client: c} +} + +func (c *mockClient) UserConfig() azdext.UserConfigServiceClient { + return &mockUserConfigClient{client: c} +} + +func (c *mockClient) expectConfig(response *azdext.GetUserConfigResponse, err error) { + c.On("UserConfig.Get", mock.Anything, &azdext.GetUserConfigRequest{Path: "alpha"}). + Return(response, err).Once() +} + +func (c *mockClient) expectProject(response *azdext.GetProjectResponse, err error) { + c.On("Project.Get", mock.Anything, &azdext.EmptyRequest{}).Return(response, err).Once() +} + +func (c *mockClient) expectDotenv(name string, response *azdext.KeyValueListResponse, err error) { + c.On("Environment.GetValues", mock.Anything, &azdext.GetEnvironmentRequest{Name: name}). + Return(response, err).Once() +} + +func (c *mockClient) expectSources( + configResponse *azdext.GetUserConfigResponse, + project *azdext.ProjectConfig, + dotenv map[string]string, + name string, +) { + c.expectConfig(configResponse, nil) + c.expectProject(&azdext.GetProjectResponse{Project: project}, nil) + values := &azdext.KeyValueListResponse{} + for key, value := range dotenv { + values.KeyValues = append(values.KeyValues, &azdext.KeyValue{Key: key, Value: value}) + } + c.expectDotenv(name, values, nil) +} + +type mockProjectClient struct { + azdext.ProjectServiceClient + client *mockClient +} + +func (c *mockProjectClient) Get( + ctx context.Context, + request *azdext.EmptyRequest, + _ ...grpc.CallOption, +) (*azdext.GetProjectResponse, error) { + args := c.client.MethodCalled("Project.Get", ctx, request) + response, ok := args.Get(0).(*azdext.GetProjectResponse) + require.True(c.client.t, ok) + return response, args.Error(1) +} + +type mockEnvironmentClient struct { + azdext.EnvironmentServiceClient + client *mockClient +} + +func (c *mockEnvironmentClient) GetValues( + ctx context.Context, + request *azdext.GetEnvironmentRequest, + _ ...grpc.CallOption, +) (*azdext.KeyValueListResponse, error) { + args := c.client.MethodCalled("Environment.GetValues", ctx, request) + response, ok := args.Get(0).(*azdext.KeyValueListResponse) + require.True(c.client.t, ok) + return response, args.Error(1) +} + +type mockUserConfigClient struct { + azdext.UserConfigServiceClient + client *mockClient +} + +func (c *mockUserConfigClient) Get( + ctx context.Context, + request *azdext.GetUserConfigRequest, + _ ...grpc.CallOption, +) (*azdext.GetUserConfigResponse, error) { + args := c.client.MethodCalled("UserConfig.Get", ctx, request) + response, ok := args.Get(0).(*azdext.GetUserConfigResponse) + require.True(c.client.t, ok) + return response, args.Error(1) +} From 2572a985b3783f0a7736638120c76186b86b6e4c Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 21 Oct 2026 15:01:45 +0800 Subject: [PATCH 2/7] feat: scope preview feature flags to the agents extension --- .../docs/specs/preview-feature-flags.md | 114 ++++++++++++++++++ cli/azd/extensions/azure.ai.agents/go.mod | 11 ++ cli/azd/extensions/azure.ai.agents/go.sum | 8 ++ .../internal}/featureflags/featureflags.go | 18 +-- .../featureflags/featureflags_test.go | 0 cli/azd/extensions/featureflags/README.md | 56 --------- 6 files changed, 142 insertions(+), 65 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md rename cli/azd/extensions/{ => azure.ai.agents/internal}/featureflags/featureflags.go (89%) rename cli/azd/extensions/{ => azure.ai.agents/internal}/featureflags/featureflags_test.go (100%) delete mode 100644 cli/azd/extensions/featureflags/README.md diff --git a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md new file mode 100644 index 00000000000..f36d3deb978 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md @@ -0,0 +1,114 @@ +# Agents extension preview feature flags + + + +## Status and goal + +- **Component:** `cli/azd/extensions/azure.ai.agents` +- **Scope:** Internal opt-in mechanism and design; no command adoption in this change. + +Keep preview opt-in decisions inside the Microsoft Foundry agents extension, without adding an azd core feature, registry, or public extension SDK API. The intended adoption scope is limited to **managed harness agents**, **voice agents**, and **long-running agent invocation**. General hosted agents and plain prompt functionality are not intended gates. + +## Ownership and code organization + +```text +azure.ai.agents/ +├── internal/ +│ ├── cmd/ # Command discovery and runtime callers +│ ├── project/ # Project/service integration callers +│ └── featureflags/ +│ ├── featureflags.go # Validation, source reads, evaluation +│ └── featureflags_test.go # Source combinations and regressions +└── docs/specs/ + └── preview-feature-flags.md +``` + +Dependency direction is `internal/cmd` or `internal/project` → `internal/featureflags` → existing azd services through `azdext.AzdClient` (`UserConfig`, `Project`, `Environment`). The helper also uses the existing `pkg/config` reader to preserve nested alpha key lookup semantics. It never imports command or project orchestration. + +The package belongs to the `azureaiagent` module and is imported as `azureaiagent/internal/featureflags`. Go's `internal` boundary excludes other extension modules. It requires neither a new module nor a root-module replacement or dependency upgrade to expose the helper. + +The host-sentinel compatibility tests import the pinned azd environment packages. Their transitive dependencies require indirect entries for `heredoc/v2`, `azblob`, and `azfile` in this module; the azd requirement stays at `v1.35.1`. + +Keep validation, reads, and small evaluation helpers together in one implementation file. Splitting them into a loader framework, registry, or generic source abstraction would add indirection without another implementation to support. + +## API and caller contract + +```go +type Feature struct { + ID string + EnvVar string +} + +func IsEnabled( + ctx context.Context, + client *azdext.AzdClient, + feature Feature, + environmentName string, +) (bool, error) +``` + +Callers declare the feature identity; the helper does not register features or derive environment keys. For example, the following voice identity is illustrative, not a production declaration or final naming decision: + +```go +voice := featureflags.Feature{ + ID: "agents.voice", + EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_VOICE", +} +enabled, err := featureflags.IsEnabled( + extCtx.Context(), + azdClient, + voice, + extCtx.Environment, +) +``` + +`IsEnabled` rejects a nil client or an empty `ID` or `EnvVar` before reading sources. Pass the parsed `azdext.ExtensionContext.Environment` unchanged. A nonempty name is forwarded to `Environment.GetValues`, so explicit `-e/--environment` takes precedence over the project default. An empty name delegates default selection to the host; the helper does not infer selection from ambient `AZD_ENVIRONMENT`. + +## Four independent enablement sources + +A feature is enabled if **any** of these sources opts in: + +| Source | Read | Enabling value | +| --- | --- | --- | +| User config | `UserConfig.Get("alpha")` (`azd config set`) | Case-insensitive `on` at `alpha.` or `alpha.all`, matching core config parsing | +| Process environment | Only the declared `EnvVar` | A true value accepted by `strconv.ParseBool` | +| Selected azd environment | Exact `EnvVar` in raw `.env` values from `Environment.GetValues` (`azd env set`) | A true value accepted by `strconv.ParseBool` | +| Project service environment | Exact `EnvVar` in each `services[].env` map from `Project.Get` | A true value accepted by `strconv.ParseBool` | + +All service hosts count, including non-Foundry services. Service maps stay separate: a true value in any service wins regardless of other services' values or iteration order. These are project-wide opt-ins, not per-service permissions. + +False, `off`, empty, missing, and other non-enabling values never override another source's true value. Config requires a string `on` without surrounding whitespace; booleans and other JSON types are non-enabling. Environment values accept exactly `1`, `t`, `T`, `true`, `TRUE`, or `True`; `on`, `yes`, and whitespace-padded values do not enable. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default. + +## Reads, optional scopes, and errors + +Each call captures the declared process value, reads user config, loads the project, and reads the selected environment when a project is available. It evaluates those per-call responses without merging source maps. This is a fresh per-invocation view, not a transaction across host reads. + +| Situation | Result | +| --- | --- | +| No project, no explicit environment | Skip project and dotenv sources; process and config still apply | +| Project without a default environment, no explicit selection | Skip dotenv; process, config, and every service still apply | +| Explicit selection with no project or a missing environment | Return the error; do not fall back to the default | +| Config/project/dotenv read, decoding, permission, or transport failure | Return an error even when another source enables the feature | +| Missing config/project/environment response, or malformed config JSON | Return an error rather than treating a failed load as disabled | + +Only the host's exact no-project and no-default-environment sentinel messages, transported as `Unknown` without details (or as matching plain errors), qualify as optional absence. Similar messages, `NotFound` statuses, detailed statuses, and absence at the wrong read stage remain errors. Tests compare the sentinel text with the pinned azd dependency; changes to the host contract require explicit compatibility review. + +Errors retain their cause with `%w` and add read-stage/feature context. A failed call returns `false, error`; callers must not use it as a successful disabled result. User-facing orchestration can classify errors following the [extension error guidance](../../../../docs/extensions/extensions-style-guide.md#error-handling-in-extensions). + +There is no global cache, process environment mutation, output, configuration write, or authentication API call. Calls use the supplied context. The helper never prints source values. + +## Adoption boundary and trade-offs + +Command discovery and runtime enforcement are separate caller responsibilities. A future discovery check may hide a preview-only command, but that alone cannot gate preview flags on existing commands, project/service flows, or direct invocation paths. Runtime callers must check the selected capability before its side effects and forward the actual environment selection. No command registration, command behavior, or runtime gate changes in this PR. + +Only the three intended capabilities listed above are candidates for adoption. Agents-private checks do **not** automatically intercept evaluations or routines sending generic requests to existing agents. Those extensions cannot import this package, and this design makes no cross-extension enforcement claim. + +The small API deliberately returns only an enablement decision and error, not a source report, registry entry, or policy object. OR semantics avoid accidental disabling by unrelated scopes, at the cost of having no local override for an existing opt-in. Exact optional-error matching avoids swallowing real failures but remains coupled to the existing host error contract. + +Adoption still needs to settle the final three identities/environment keys, exact command and runtime check locations, and disabled-feature guidance for each capability. Those decisions belong with the relevant behavior changes, not speculative declarations here. + +## Validation + +Tests cover all 16 combinations of the four sources, all supported boolean spellings and non-enabling values, nested alpha config and `alpha.all`, exact environment keys, every service host and conflicting service order, explicit environment forwarding, optional scopes and sentinel compatibility, genuine failures despite positive sources, malformed responses, context forwarding, and snapshot isolation without input mutation. + +Run focused tests and vet for `./internal/featureflags`, build the agents module against its pinned azd dependency, and apply the module's formatting/lint rules. No Azure scenario or user auth/config mutation is needed for this mechanism-only change. diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 137288c6734..165e3d16417 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -50,10 +50,15 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 // indirect + github.com/MakeNowJust/heredoc/v2 v2.0.1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b // indirect github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/apache/arrow-go/v18 v18.7.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -77,8 +82,10 @@ require ( github.com/dlclark/regexp2/v2 v2.9.1 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -86,6 +93,8 @@ require ( github.com/jmespath-community/go-jmespath v1.1.1 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-colorable v0.1.16 // indirect @@ -101,6 +110,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pb33f/go-yaml v0.1.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.2 // indirect + github.com/pierrec/lz4/v4 v4.1.28 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -112,6 +122,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.8.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.47.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index ae8b2e2a6ab..5c60d4de183 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -35,6 +35,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1. github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 h1:aMFOzch6ZJo4Ct9hI4A9Y2fPen5YNRTPmkSBhe5m0ZQ= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0/go.mod h1:Oct8bx+g+DXKngU7i/LzFzYt44rmLdMu4uoofIpooVo= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= @@ -63,8 +65,12 @@ github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apache/arrow-go/v18 v18.7.0 h1:Vw/i+cJyebUofT7JlqFpe65LrmwxULn166jjwStM4HY= github.com/apache/arrow-go/v18 v18.7.0/go.mod h1:PM6IigLJkdMwIpeHXnymo+xZ52f42a9EYiLtRel4p/A= +github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ= +github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -283,6 +289,8 @@ github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/cli/azd/extensions/featureflags/featureflags.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go similarity index 89% rename from cli/azd/extensions/featureflags/featureflags.go rename to cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go index 032d702d00d..a9e0d0c404f 100644 --- a/cli/azd/extensions/featureflags/featureflags.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go @@ -3,7 +3,7 @@ // cspell:ignore featureflags -// Package featureflags resolves shared Foundry extension opt-ins. +// Package featureflags resolves agents extension preview opt-ins. package featureflags import ( @@ -23,10 +23,9 @@ import ( // Feature declares one extension opt-in. type Feature struct { - // ID is the user config key under alpha, for example "agents.hosted". + // ID is the user config key under alpha. ID string - // EnvVar is the only environment key read, for example - // "AZD_FOUNDRY_EXT_ENABLE_AGENTS_HOSTED". + // EnvVar is the exact environment key read for this feature. EnvVar string } @@ -36,9 +35,10 @@ const ( enabledValue = "on" ) -// Host errors for an absent optional scope. They match -// azdcontext.ErrNoProject and environment.ErrDefaultEnvironmentNotFound -// without importing those packages and their dependencies. +// Host errors for an absent optional scope. They match the sentinels +// azdcontext.ErrNoProject and +// environment.ErrDefaultEnvironmentNotFound without importing +// those packages and their dependencies. var ( errNoProject = errors.New("no project exists; to create a new project, run `azd init`") errDefaultEnvironmentNotFound = errors.New("default environment not found") @@ -122,7 +122,7 @@ func isEnabled( } } - // Each service map counts on its own, so one false never hides another true. + // Each service counts independently; false never hides another true. for _, service := range projectResponse.Project.Services { if envEnabled(service.GetEnvironment()[feature.EnvVar]) { enabled = true @@ -139,7 +139,7 @@ func envEnabled(value string) bool { return err == nil && enabled } -// configEnabled matches azd core: "on" (any case) at alpha.all or alpha.. +// configEnabled matches "on" (any case) at alpha.all or alpha.. // Other values, including non-string values, contribute no signal. func configEnabled(alphaValues any, id string) bool { userConfig := config.NewConfig(map[string]any{alphaKey: alphaValues}) diff --git a/cli/azd/extensions/featureflags/featureflags_test.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go similarity index 100% rename from cli/azd/extensions/featureflags/featureflags_test.go rename to cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go diff --git a/cli/azd/extensions/featureflags/README.md b/cli/azd/extensions/featureflags/README.md deleted file mode 100644 index 05d6fdd316a..00000000000 --- a/cli/azd/extensions/featureflags/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Shared Foundry feature access - - - -`featureflags` is the common opt-in reader for first-party Foundry extensions. It is self-contained and needs no azd core change. It reads only through existing azd gRPC services (`UserConfig`, `Environment`, `Project`). - -It belongs to the root `cli/azd` Go module, like the shared `extensions/telemetry` package. An extension module imports it as `github.com/azure/azure-dev/cli/azd/extensions/featureflags` once its `github.com/azure/azure-dev/cli/azd` requirement points at a version that contains this package. - -This package adds access only. No existing command or feature uses it yet. - -## API - -```go -var hosted = featureflags.Feature{ - ID: "agents.hosted", - EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_HOSTED", -} - -enabled, err := featureflags.IsEnabled( - extCtx.Context(), - azdClient, - hosted, - extCtx.Environment, -) -``` - -Each extension declares its own `Feature`. There is no shared registry and no change to the core alpha feature list. - -Pass the parsed `azdext.ExtensionContext.Environment` rather than discovering the default yourself. A nonempty name is forwarded to `Environment.GetValues` unchanged, so `-e/--environment` takes precedence over the project default. An invalid explicit selection is an error. An empty name uses the default environment if there is one. - -## Independent enablement sources - -A feature is enabled if **any** of these sources opts in: - -| Source | Read | Enabling value | -| --- | --- | --- | -| Global user config | `UserConfig.Get("alpha")` (`azd config set`) | Case-insensitive `on` at `alpha.` or `alpha.all`, same as azd core | -| Process environment | Only `EnvVar` | A true value accepted by `strconv.ParseBool` | -| Selected azd environment | `EnvVar` in its `.env` values from `Environment.GetValues` (`azd env set`) | A true value accepted by `strconv.ParseBool` | -| Project service environment | `EnvVar` in each `services[].env` map from `Project.Get` | A true value accepted by `strconv.ParseBool` | - -Canonical environment names are: - -- `AZD_FOUNDRY_EXT_ENABLE_` for shared capabilities. -- `AZD_FOUNDRY_EXT_ENABLE_AGENTS_` for agent-specific capabilities. -- `AZD_FOUNDRY_EXT_ENABLE_MODELS_` for model-specific capabilities. - -All service hosts count. Service maps stay separate, so a true value in any service wins regardless of other services' values or order. - -False, empty, missing, and unrecognized values contribute no signal and never override another source's opt-in. Non-string config values are also non-enabling. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default. - -## Scope and errors - -Each call loads fresh snapshots and discards them. There is no global cache, process environment mutation, output, authentication call, or configuration write. - -Outside a project, only process environment and user config apply. In a project without a default environment, process, config, and service opt-ins still apply. Only the host's exact no-project and no-default-environment errors count as absence, and only when no environment name was supplied. Other project, dotenv, config, decoding, and transport errors are returned with context, even when another source opts in. From b734f2062eedca72544f6dc4e4c9e6f662417e26 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 21 Oct 2026 17:48:53 +0800 Subject: [PATCH 3/7] fix: require per-feature config opt-ins for agents previews --- .../docs/specs/preview-feature-flags.md | 6 +- .../internal/featureflags/featureflags.go | 12 +--- .../featureflags/featureflags_test.go | 60 ++++++++++++++++++- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md index f36d3deb978..b33a0d560e1 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md +++ b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md @@ -70,13 +70,15 @@ A feature is enabled if **any** of these sources opts in: | Source | Read | Enabling value | | --- | --- | --- | -| User config | `UserConfig.Get("alpha")` (`azd config set`) | Case-insensitive `on` at `alpha.` or `alpha.all`, matching core config parsing | +| User config | `UserConfig.Get("alpha")` (`azd config set`) | Case-insensitive string `on` only at `alpha.` | | Process environment | Only the declared `EnvVar` | A true value accepted by `strconv.ParseBool` | | Selected azd environment | Exact `EnvVar` in raw `.env` values from `Environment.GetValues` (`azd env set`) | A true value accepted by `strconv.ParseBool` | | Project service environment | Exact `EnvVar` in each `services[].env` map from `Project.Get` | A true value accepted by `strconv.ParseBool` | All service hosts count, including non-Foundry services. Service maps stay separate: a true value in any service wins regardless of other services' values or iteration order. These are project-wide opt-ins, not per-service permissions. +The agents reader ignores `alpha.all` and unrelated config keys. A global opt-in cannot enable an agents preview, and global or unrelated values cannot cancel a per-feature or environment opt-in. Core azd's `alpha.all` behavior is unchanged. + False, `off`, empty, missing, and other non-enabling values never override another source's true value. Config requires a string `on` without surrounding whitespace; booleans and other JSON types are non-enabling. Environment values accept exactly `1`, `t`, `T`, `true`, `TRUE`, or `True`; `on`, `yes`, and whitespace-padded values do not enable. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default. ## Reads, optional scopes, and errors @@ -109,6 +111,6 @@ Adoption still needs to settle the final three identities/environment keys, exac ## Validation -Tests cover all 16 combinations of the four sources, all supported boolean spellings and non-enabling values, nested alpha config and `alpha.all`, exact environment keys, every service host and conflicting service order, explicit environment forwarding, optional scopes and sentinel compatibility, genuine failures despite positive sources, malformed responses, context forwarding, and snapshot isolation without input mutation. +Tests cover all 16 combinations of the four sources, all supported boolean spellings and non-enabling values, nested per-feature alpha config, ignored `alpha.all` and unrelated keys, exact environment keys, every service host and conflicting service order, explicit environment forwarding, optional scopes and sentinel compatibility, genuine failures despite positive sources, malformed responses, context forwarding, and snapshot isolation without input mutation. Run focused tests and vet for `./internal/featureflags`, build the agents module against its pinned azd dependency, and apply the module's formatting/lint rules. No Azure scenario or user auth/config mutation is needed for this mechanism-only change. diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go index a9e0d0c404f..50d95e1f92b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go @@ -31,7 +31,6 @@ type Feature struct { const ( alphaKey = "alpha" - allFeatureID = "all" enabledValue = "on" ) @@ -139,17 +138,12 @@ func envEnabled(value string) bool { return err == nil && enabled } -// configEnabled matches "on" (any case) at alpha.all or alpha.. +// configEnabled matches "on" (any case) only at alpha.. // Other values, including non-string values, contribute no signal. func configEnabled(alphaValues any, id string) bool { userConfig := config.NewConfig(map[string]any{alphaKey: alphaValues}) - for _, key := range []string{allFeatureID, id} { - if value, ok := userConfig.GetString(alphaKey + "." + key); ok && strings.EqualFold(value, enabledValue) { - return true - } - } - - return false + value, ok := userConfig.GetString(alphaKey + "." + id) + return ok && strings.EqualFold(value, enabledValue) } func isAbsentScope(err error, sentinel error) bool { diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go index fdc5012971e..d31637215cb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go @@ -224,7 +224,61 @@ func TestConfigValues(t *testing.T) { enabled, err := isEnabled(t.Context(), client, fixture, "") require.NoError(t, err) - require.Equal(t, tt.want, enabled) + require.Equal(t, path == "feature" && tt.want, enabled) + }) + } + } +} + +func TestGlobalConfigDoesNotEnableAgentsFeatures(t *testing.T) { + for _, id := range []string{"agents.managed_harness", "agents.voice", "agents.long_running"} { + for _, value := range []any{"on", "ON", "On", "oN", "off", "", true, nil} { + t.Run(id+"/"+fmt.Sprint(value), func(t *testing.T) { + alphaValues := map[string]any{ + "all": value, + "agents": map[string]any{ + "unrelated": "on", + }, + } + + require.False(t, configEnabled(alphaValues, id)) + }) + } + } +} + +func TestUnrelatedConfigDoesNotOverrideOptIn(t *testing.T) { + for _, value := range []any{"on", "ON", "On", "oN", "off", "", false, nil} { + for _, source := range []string{"config", "process", "dotenv", "service"} { + t.Run(fmt.Sprint(value)+"/"+source, func(t *testing.T) { + t.Setenv(fixture.EnvVar, "false") + configValue := "off" + dotenv := map[string]string{} + svc := service("containerapp", "false") + switch source { + case "config": + configValue = "on" + case "process": + t.Setenv(fixture.EnvVar, "true") + case "dotenv": + dotenv[fixture.EnvVar] = "true" + case "service": + svc.Environment[fixture.EnvVar] = "true" + } + response := alphaResponse(t, configValue, value) + var alphaValues map[string]any + require.NoError(t, json.Unmarshal(response.Value, &alphaValues)) + alphaValues["unrelated"] = value + data, err := json.Marshal(alphaValues) + require.NoError(t, err) + response.Value = data + client := newMockClient(t) + client.expectSources(response, projectWithServices(svc), dotenv, "") + + enabled, err := isEnabled(t.Context(), client, fixture, "") + + require.NoError(t, err) + require.True(t, enabled) }) } } @@ -436,7 +490,7 @@ func TestSourceErrorsAreReturned(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Setenv(fixture.EnvVar, "true") client := newMockClient(t) - configResponse := alphaResponse(t, "on", "on") + configResponse := alphaResponse(t, "on", "off") switch tt.stage { case "config": client.expectConfig(nil, tt.cause) @@ -497,7 +551,7 @@ func TestMalformedResponses(t *testing.T) { case "decode": client.expectConfig(&azdext.GetUserConfigResponse{Found: true, Value: tt.value}, nil) default: - client.expectConfig(alphaResponse(t, "on", "on"), nil) + client.expectConfig(alphaResponse(t, "on", "off"), nil) switch tt.stage { case "project": client.expectProject(nil, nil) From d050ef1f56a0ff0a3d476613fc0b65b90a4d0dc1 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 21 Oct 2026 18:15:57 +0800 Subject: [PATCH 4/7] fix: limit feature flag docs to documented opt-in methods --- .../docs/specs/preview-feature-flags.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md index b33a0d560e1..39fe2eaf923 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md +++ b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md @@ -64,18 +64,15 @@ enabled, err := featureflags.IsEnabled( `IsEnabled` rejects a nil client or an empty `ID` or `EnvVar` before reading sources. Pass the parsed `azdext.ExtensionContext.Environment` unchanged. A nonempty name is forwarded to `Environment.GetValues`, so explicit `-e/--environment` takes precedence over the project default. An empty name delegates default selection to the host; the helper does not infer selection from ambient `AZD_ENVIRONMENT`. -## Four independent enablement sources +## Documented enablement methods -A feature is enabled if **any** of these sources opts in: +The documented ways to opt into a feature are listed below. These opt-ins are additive: any enabling value is sufficient. | Source | Read | Enabling value | | --- | --- | --- | | User config | `UserConfig.Get("alpha")` (`azd config set`) | Case-insensitive string `on` only at `alpha.` | | Process environment | Only the declared `EnvVar` | A true value accepted by `strconv.ParseBool` | | Selected azd environment | Exact `EnvVar` in raw `.env` values from `Environment.GetValues` (`azd env set`) | A true value accepted by `strconv.ParseBool` | -| Project service environment | Exact `EnvVar` in each `services[].env` map from `Project.Get` | A true value accepted by `strconv.ParseBool` | - -All service hosts count, including non-Foundry services. Service maps stay separate: a true value in any service wins regardless of other services' values or iteration order. These are project-wide opt-ins, not per-service permissions. The agents reader ignores `alpha.all` and unrelated config keys. A global opt-in cannot enable an agents preview, and global or unrelated values cannot cancel a per-feature or environment opt-in. Core azd's `alpha.all` behavior is unchanged. @@ -88,7 +85,7 @@ Each call captures the declared process value, reads user config, loads the proj | Situation | Result | | --- | --- | | No project, no explicit environment | Skip project and dotenv sources; process and config still apply | -| Project without a default environment, no explicit selection | Skip dotenv; process, config, and every service still apply | +| Project without a default environment, no explicit selection | Skip dotenv; process and config still apply | | Explicit selection with no project or a missing environment | Return the error; do not fall back to the default | | Config/project/dotenv read, decoding, permission, or transport failure | Return an error even when another source enables the feature | | Missing config/project/environment response, or malformed config JSON | Return an error rather than treating a failed load as disabled | @@ -111,6 +108,6 @@ Adoption still needs to settle the final three identities/environment keys, exac ## Validation -Tests cover all 16 combinations of the four sources, all supported boolean spellings and non-enabling values, nested per-feature alpha config, ignored `alpha.all` and unrelated keys, exact environment keys, every service host and conflicting service order, explicit environment forwarding, optional scopes and sentinel compatibility, genuine failures despite positive sources, malformed responses, context forwarding, and snapshot isolation without input mutation. +Tests cover source combinations, all supported boolean spellings and non-enabling values, nested per-feature alpha config, ignored `alpha.all` and unrelated keys, exact environment keys, explicit environment forwarding, optional scopes and sentinel compatibility, genuine failures despite positive sources, malformed responses, context forwarding, and snapshot isolation without input mutation. Run focused tests and vet for `./internal/featureflags`, build the agents module against its pinned azd dependency, and apply the module's formatting/lint rules. No Azure scenario or user auth/config mutation is needed for this mechanism-only change. From 495ee0df9920f5d1201738c22d8dfedee78d1863 Mon Sep 17 00:00:00 2001 From: Hui Miao Date: Wed, 21 Oct 2026 20:14:26 +0800 Subject: [PATCH 5/7] feat: declare agents preview features in one registry Add an optional Description to Feature and a registry with All and Lookup so every agents preview is declared in one place. --- .../docs/specs/preview-feature-flags.md | 60 ++++++++++++------- .../internal/featureflags/featureflags.go | 2 + .../featureflags/featureflags_test.go | 3 +- .../internal/featureflags/registry.go | 44 ++++++++++++++ .../internal/featureflags/registry_test.go | 50 ++++++++++++++++ 5 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/featureflags/registry_test.go diff --git a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md index 39fe2eaf923..87ca6b45efa 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md +++ b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md @@ -5,9 +5,9 @@ ## Status and goal - **Component:** `cli/azd/extensions/azure.ai.agents` -- **Scope:** Internal opt-in mechanism and design; no command adoption in this change. +- **Scope:** Internal opt-in mechanism, the feature registry, and design. No command adoption in this change. -Keep preview opt-in decisions inside the Microsoft Foundry agents extension, without adding an azd core feature, registry, or public extension SDK API. The intended adoption scope is limited to **managed harness agents**, **voice agents**, and **long-running agent invocation**. General hosted agents and plain prompt functionality are not intended gates. +Keep preview opt-in decisions inside the Microsoft Foundry agents extension, without adding an azd core feature registry or public extension SDK API. The adoption scope is limited to **managed harness agents**, **voice agents**, and **long-running agent invocation**. General hosted agents and plain prompt functionality are not gated. ## Ownership and code organization @@ -18,7 +18,9 @@ azure.ai.agents/ │ ├── project/ # Project/service integration callers │ └── featureflags/ │ ├── featureflags.go # Validation, source reads, evaluation -│ └── featureflags_test.go # Source combinations and regressions +│ ├── featureflags_test.go # Source combinations and regressions +│ ├── registry.go # Every declared feature and All +│ └── registry_test.go # Declaration rules └── docs/specs/ └── preview-feature-flags.md ``` @@ -27,16 +29,17 @@ Dependency direction is `internal/cmd` or `internal/project` → `internal/featu The package belongs to the `azureaiagent` module and is imported as `azureaiagent/internal/featureflags`. Go's `internal` boundary excludes other extension modules. It requires neither a new module nor a root-module replacement or dependency upgrade to expose the helper. -The host-sentinel compatibility tests import the pinned azd environment packages. Their transitive dependencies require indirect entries for `heredoc/v2`, `azblob`, and `azfile` in this module; the azd requirement stays at `v1.35.1`. +The host-sentinel compatibility tests import the pinned azd environment packages. Their transitive dependencies require indirect entries for `heredoc/v2`, `azblob`, and `azfile` in this module. The azd requirement stays at `v1.35.1`. -Keep validation, reads, and small evaluation helpers together in one implementation file. Splitting them into a loader framework, registry, or generic source abstraction would add indirection without another implementation to support. +Keep validation, reads, and small evaluation helpers together in one implementation file. Splitting them into a loader framework or generic source abstraction would add indirection without another implementation to support. The registry is a plain list of declarations, not a plugin system. ## API and caller contract ```go type Feature struct { - ID string - EnvVar string + ID string + EnvVar string + Description string } func IsEnabled( @@ -45,24 +48,33 @@ func IsEnabled( feature Feature, environmentName string, ) (bool, error) + +func All() []Feature +func Lookup(id string) (Feature, bool) ``` -Callers declare the feature identity; the helper does not register features or derive environment keys. For example, the following voice identity is illustrative, not a production declaration or final naming decision: +## Feature registry + +`registry.go` declares every agents preview feature in one place. `All` returns them in a stable order as a fresh slice. `Lookup` finds a declaration by ID. Command help, enforcement, and listings read this list instead of declaring their own identities. + +| Declaration | Feature ID | Environment key | Scope | +| --- | --- | --- | --- | +| `ManagedHarness` | `agents.managed_harness` | `AZD_FOUNDRY_EXT_ENABLE_AGENTS_MANAGED_HARNESS` | Prompt agents that run on a Copilot harness | +| `Voice` | `agents.voice` | `AZD_FOUNDRY_EXT_ENABLE_AGENTS_VOICE` | Voice, prompt-voice, and hosted voice agents | +| `LongRunning` | `agents.long_running` | `AZD_FOUNDRY_EXT_ENABLE_AGENTS_LONG_RUNNING` | Long-running Responses invocations | + +Each ID starts with `agents.`. Each environment key is `AZD_FOUNDRY_EXT_ENABLE_` plus the ID in upper case with dots replaced by underscores. Tests enforce both rules, unique IDs and keys, and a nonempty description. The helper still reads `EnvVar` as declared and never derives it at run time. ```go -voice := featureflags.Feature{ - ID: "agents.voice", - EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_VOICE", -} enabled, err := featureflags.IsEnabled( extCtx.Context(), azdClient, - voice, + featureflags.Voice, extCtx.Environment, ) ``` -`IsEnabled` rejects a nil client or an empty `ID` or `EnvVar` before reading sources. Pass the parsed `azdext.ExtensionContext.Environment` unchanged. A nonempty name is forwarded to `Environment.GetValues`, so explicit `-e/--environment` takes precedence over the project default. An empty name delegates default selection to the host; the helper does not infer selection from ambient `AZD_ENVIRONMENT`. +`IsEnabled` rejects a nil client or an empty `ID` or `EnvVar` before reading sources. `Description` is optional and never read by the evaluator. Pass the parsed `azdext.ExtensionContext.Environment` unchanged. A nonempty name is forwarded to `Environment.GetValues`, so explicit `-e/--environment` takes precedence over the project default. An empty name delegates default selection to the host. The helper does not infer selection from ambient `AZD_ENVIRONMENT`. ## Documented enablement methods @@ -76,7 +88,7 @@ The documented ways to opt into a feature are listed below. These opt-ins are ad The agents reader ignores `alpha.all` and unrelated config keys. A global opt-in cannot enable an agents preview, and global or unrelated values cannot cancel a per-feature or environment opt-in. Core azd's `alpha.all` behavior is unchanged. -False, `off`, empty, missing, and other non-enabling values never override another source's true value. Config requires a string `on` without surrounding whitespace; booleans and other JSON types are non-enabling. Environment values accept exactly `1`, `t`, `T`, `true`, `TRUE`, or `True`; `on`, `yes`, and whitespace-padded values do not enable. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default. +False, `off`, empty, missing, and other non-enabling values never override another source's true value. Config requires a string `on` without surrounding whitespace. Booleans and other JSON types are non-enabling. Environment values accept exactly `1`, `t`, `T`, `true`, `TRUE`, or `True`. Values such as `on`, `yes`, and whitespace-padded values do not enable. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default. ## Reads, optional scopes, and errors @@ -84,15 +96,15 @@ Each call captures the declared process value, reads user config, loads the proj | Situation | Result | | --- | --- | -| No project, no explicit environment | Skip project and dotenv sources; process and config still apply | -| Project without a default environment, no explicit selection | Skip dotenv; process and config still apply | -| Explicit selection with no project or a missing environment | Return the error; do not fall back to the default | +| No project, no explicit environment | Skip project and dotenv sources. Process and config still apply | +| Project without a default environment, no explicit selection | Skip dotenv. Process and config still apply | +| Explicit selection with no project or a missing environment | Return the error. Do not fall back to the default | | Config/project/dotenv read, decoding, permission, or transport failure | Return an error even when another source enables the feature | | Missing config/project/environment response, or malformed config JSON | Return an error rather than treating a failed load as disabled | -Only the host's exact no-project and no-default-environment sentinel messages, transported as `Unknown` without details (or as matching plain errors), qualify as optional absence. Similar messages, `NotFound` statuses, detailed statuses, and absence at the wrong read stage remain errors. Tests compare the sentinel text with the pinned azd dependency; changes to the host contract require explicit compatibility review. +Only the host's exact no-project and no-default-environment sentinel messages, transported as `Unknown` without details (or as matching plain errors), qualify as optional absence. Similar messages, `NotFound` statuses, detailed statuses, and absence at the wrong read stage remain errors. Tests compare the sentinel text with the pinned azd dependency. Changes to the host contract require explicit compatibility review. -Errors retain their cause with `%w` and add read-stage/feature context. A failed call returns `false, error`; callers must not use it as a successful disabled result. User-facing orchestration can classify errors following the [extension error guidance](../../../../docs/extensions/extensions-style-guide.md#error-handling-in-extensions). +Errors retain their cause with `%w` and add read-stage/feature context. A failed call returns `false, error`. Callers must not use it as a successful disabled result. User-facing orchestration can classify errors following the [extension error guidance](../../../../docs/extensions/extensions-style-guide.md#error-handling-in-extensions). There is no global cache, process environment mutation, output, configuration write, or authentication API call. Calls use the supplied context. The helper never prints source values. @@ -100,14 +112,16 @@ There is no global cache, process environment mutation, output, configuration wr Command discovery and runtime enforcement are separate caller responsibilities. A future discovery check may hide a preview-only command, but that alone cannot gate preview flags on existing commands, project/service flows, or direct invocation paths. Runtime callers must check the selected capability before its side effects and forward the actual environment selection. No command registration, command behavior, or runtime gate changes in this PR. -Only the three intended capabilities listed above are candidates for adoption. Agents-private checks do **not** automatically intercept evaluations or routines sending generic requests to existing agents. Those extensions cannot import this package, and this design makes no cross-extension enforcement claim. +Only the three registered capabilities are candidates for adoption. Agents-private checks do **not** automatically intercept evaluations or routines sending generic requests to existing agents. Those extensions cannot import this package, and this design makes no cross-extension enforcement claim. -The small API deliberately returns only an enablement decision and error, not a source report, registry entry, or policy object. OR semantics avoid accidental disabling by unrelated scopes, at the cost of having no local override for an existing opt-in. Exact optional-error matching avoids swallowing real failures but remains coupled to the existing host error contract. +The small API deliberately returns only an enablement decision and error, not a source report or policy object. OR semantics avoid accidental disabling by unrelated scopes, at the cost of having no local override for an existing opt-in. Exact optional-error matching avoids swallowing real failures but remains coupled to the existing host error contract. -Adoption still needs to settle the final three identities/environment keys, exact command and runtime check locations, and disabled-feature guidance for each capability. Those decisions belong with the relevant behavior changes, not speculative declarations here. +The registry settles the three identities and environment keys. Exact command and runtime check locations and disabled-feature guidance belong with the behavior changes that adopt them. ## Validation Tests cover source combinations, all supported boolean spellings and non-enabling values, nested per-feature alpha config, ignored `alpha.all` and unrelated keys, exact environment keys, explicit environment forwarding, optional scopes and sentinel compatibility, genuine failures despite positive sources, malformed responses, context forwarding, and snapshot isolation without input mutation. +Registry tests cover declaration rules, the stable copy returned by `All`, and `Lookup`. + Run focused tests and vet for `./internal/featureflags`, build the agents module against its pinned azd dependency, and apply the module's formatting/lint rules. No Azure scenario or user auth/config mutation is needed for this mechanism-only change. diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go index 50d95e1f92b..5da363c10c8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go @@ -27,6 +27,8 @@ type Feature struct { ID string // EnvVar is the exact environment key read for this feature. EnvVar string + // Description is a short summary for listings. It is optional. + Description string } const ( diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go index d31637215cb..d9f625aafde 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go @@ -231,7 +231,8 @@ func TestConfigValues(t *testing.T) { } func TestGlobalConfigDoesNotEnableAgentsFeatures(t *testing.T) { - for _, id := range []string{"agents.managed_harness", "agents.voice", "agents.long_running"} { + for _, feature := range All() { + id := feature.ID for _, value := range []any{"on", "ON", "On", "oN", "off", "", true, nil} { t.Run(id+"/"+fmt.Sprint(value), func(t *testing.T) { alphaValues := map[string]any{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go new file mode 100644 index 00000000000..83dc87fea6a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// cspell:ignore featureflags + +package featureflags + +// Every agents preview feature is declared here and listed by All. +var ( + // ManagedHarness controls prompt agents using a Copilot harness. + ManagedHarness = Feature{ + ID: "agents.managed_harness", + EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_MANAGED_HARNESS", + Description: "Prompt agents that run on a Copilot harness", + } + // Voice controls managed voice and hosted voice agents. + Voice = Feature{ + ID: "agents.voice", + EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_VOICE", + Description: "Voice, prompt-voice, and hosted voice agents", + } + // LongRunning controls starting background Responses invocations. + LongRunning = Feature{ + ID: "agents.long_running", + EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_LONG_RUNNING", + Description: "Long-running Responses invocations", + } +) + +// All returns every declared feature in a stable order. +// The slice is a fresh copy, so callers may change it. +func All() []Feature { + return []Feature{ManagedHarness, Voice, LongRunning} +} + +// Lookup returns the declared feature with the given ID. +func Lookup(id string) (Feature, bool) { + for _, feature := range All() { + if feature.ID == id { + return feature, true + } + } + return Feature{}, false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry_test.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry_test.go new file mode 100644 index 00000000000..3a020d263b9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry_test.go @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package featureflags + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRegistryDeclarations(t *testing.T) { + features := All() + require.Equal(t, []Feature{ManagedHarness, Voice, LongRunning}, features) + + ids := map[string]bool{} + envVars := map[string]bool{} + for _, feature := range features { + require.True(t, strings.HasPrefix(feature.ID, "agents."), feature.ID) + require.Equal(t, + "AZD_FOUNDRY_EXT_ENABLE_"+strings.ToUpper(strings.ReplaceAll(feature.ID, ".", "_")), + feature.EnvVar, + ) + require.NotEmpty(t, feature.Description) + require.False(t, ids[feature.ID], "duplicate ID %s", feature.ID) + require.False(t, envVars[feature.EnvVar], "duplicate env var %s", feature.EnvVar) + ids[feature.ID] = true + envVars[feature.EnvVar] = true + } +} + +func TestAllReturnsCopy(t *testing.T) { + features := All() + features[0].ID = "changed" + require.Equal(t, ManagedHarness, All()[0]) + require.Equal(t, "agents.managed_harness", ManagedHarness.ID) +} + +func TestLookup(t *testing.T) { + for _, feature := range All() { + found, ok := Lookup(feature.ID) + require.True(t, ok) + require.Equal(t, feature, found) + } + _, ok := Lookup("agents.hosted") + require.False(t, ok) + _, ok = Lookup("") + require.False(t, ok) +} From 35ac9bd34e8e33dc274384ee040a1354395b0bf7 Mon Sep 17 00:00:00 2001 From: Hui Miao Date: Thu, 22 Oct 2026 09:25:08 +0800 Subject: [PATCH 6/7] fix: reject an explicit empty environment for preview checks Document that agents previews need their own opt-in and are not turned on by alpha.all or AZD_ALPHA_ENABLE_* variables. Add CheckEnvironmentFlag so callers reject an explicit empty --environment before reading any source. --- .../docs/specs/preview-feature-flags.md | 19 ++++- .../internal/featureflags/featureflags.go | 23 ++++- .../featureflags/featureflags_test.go | 83 ++++++++++++++++--- 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md index 87ca6b45efa..edbfaa4bed1 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md +++ b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md @@ -42,6 +42,8 @@ type Feature struct { Description string } +func CheckEnvironmentFlag(flags *pflag.FlagSet) error + func IsEnabled( ctx context.Context, client *azdext.AzdClient, @@ -53,6 +55,10 @@ func All() []Feature func Lookup(id string) (Feature, bool) ``` +Callers must call `CheckEnvironmentFlag(cmd.Flags())` before `IsEnabled` and before any source read. An explicit empty or whitespace only `-e/--environment` selection is invalid. The check returns a validation error with code `invalid_parameter`. It returns nil for a nil flag set, an absent flag, an omitted flag, or a nonempty selection. + +Pass the parsed `azdext.ExtensionContext.Environment` unchanged to `IsEnabled`. An empty name means the flag was omitted and lets the host select the project default. A nonempty name is passed to `Environment.GetValues`, so an explicit selection takes precedence over the project default. The helper does not infer selection from ambient `AZD_ENVIRONMENT`. + ## Feature registry `registry.go` declares every agents preview feature in one place. `All` returns them in a stable order as a fresh slice. `Lookup` finds a declaration by ID. Command help, enforcement, and listings read this list instead of declaring their own identities. @@ -66,6 +72,9 @@ func Lookup(id string) (Feature, bool) Each ID starts with `agents.`. Each environment key is `AZD_FOUNDRY_EXT_ENABLE_` plus the ID in upper case with dots replaced by underscores. Tests enforce both rules, unique IDs and keys, and a nonempty description. The helper still reads `EnvVar` as declared and never derives it at run time. ```go +if err := featureflags.CheckEnvironmentFlag(cmd.Flags()); err != nil { + return err +} enabled, err := featureflags.IsEnabled( extCtx.Context(), azdClient, @@ -74,7 +83,7 @@ enabled, err := featureflags.IsEnabled( ) ``` -`IsEnabled` rejects a nil client or an empty `ID` or `EnvVar` before reading sources. `Description` is optional and never read by the evaluator. Pass the parsed `azdext.ExtensionContext.Environment` unchanged. A nonempty name is forwarded to `Environment.GetValues`, so explicit `-e/--environment` takes precedence over the project default. An empty name delegates default selection to the host. The helper does not infer selection from ambient `AZD_ENVIRONMENT`. +`IsEnabled` rejects a nil client or an empty `ID` or `EnvVar` before reading sources. `Description` is optional and never read by the evaluator. ## Documented enablement methods @@ -86,9 +95,13 @@ The documented ways to opt into a feature are listed below. These opt-ins are ad | Process environment | Only the declared `EnvVar` | A true value accepted by `strconv.ParseBool` | | Selected azd environment | Exact `EnvVar` in raw `.env` values from `Environment.GetValues` (`azd env set`) | A true value accepted by `strconv.ParseBool` | -The agents reader ignores `alpha.all` and unrelated config keys. A global opt-in cannot enable an agents preview, and global or unrelated values cannot cancel a per-feature or environment opt-in. Core azd's `alpha.all` behavior is unchanged. +Unrelated config keys cannot enable a preview or cancel an opt-in from another source. + +False, `off`, empty, missing, and other non-enabling values never override another source's true value. Config requires a string `on` without surrounding whitespace. Booleans and other JSON types are non-enabling. Environment values accept exactly `1`, `t`, `T`, `true`, `TRUE`, or `True`. Values such as `on`, `yes`, and whitespace-padded values do not enable. There is no `ALL` environment toggle or experimentation default. + +### Relation to azd alpha features -False, `off`, empty, missing, and other non-enabling values never override another source's true value. Config requires a string `on` without surrounding whitespace. Booleans and other JSON types are non-enabling. Environment values accept exactly `1`, `t`, `T`, `true`, `TRUE`, or `True`. Values such as `on`, `yes`, and whitespace-padded values do not enable. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default. +Agents previews reuse the `alpha.` config key shape for `azd config set`, but are not part of the azd alpha feature set. Neither `alpha.all` nor `AZD_ALPHA_ENABLE_`, such as `AZD_ALPHA_ENABLE_AGENTS_VOICE`, turns them on. Each preview must be turned on with its own `alpha.agents.` key or its `AZD_FOUNDRY_EXT_ENABLE_*` variable. Each preview is chosen on its own, so a global switch cannot opt a user into a preview they did not choose. Core azd alpha feature behavior is unchanged. ## Reads, optional scopes, and errors diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go index 5da363c10c8..f6a3eb15d83 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go @@ -17,8 +17,11 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/spf13/pflag" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "azureaiagent/internal/exterrors" ) // Feature declares one extension opt-in. @@ -51,9 +54,27 @@ type azdClient interface { UserConfig() azdext.UserConfigServiceClient } +// CheckEnvironmentFlag rejects an explicitly empty -e/--environment selection. +func CheckEnvironmentFlag(flags *pflag.FlagSet) error { + if flags == nil { + return nil + } + flag := flags.Lookup("environment") + if flag == nil || !flag.Changed || strings.TrimSpace(flag.Value.String()) != "" { + return nil + } + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "the --environment flag was set to an empty value", + "pass an environment name with --environment, or omit the flag to use the default environment", + ) +} + // IsEnabled resolves feature for one invocation. +// Call CheckEnvironmentFlag before IsEnabled and before any source read. +// An explicit empty environment selection is invalid. // Pass ExtensionContext.Environment to honor -e/--environment. -// An empty environmentName uses the project default, if one exists. +// An empty environmentName means the flag was omitted and uses the project default, if one exists. // Loading failures are returned even when another source enables it. func IsEnabled( ctx context.Context, diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go index d9f625aafde..9fdb0fc5bf7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go @@ -17,12 +17,15 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/spf13/pflag" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "azureaiagent/internal/exterrors" ) var fixture = Feature{ @@ -30,6 +33,54 @@ var fixture = Feature{ EnvVar: "AZD_FOUNDRY_EXT_ENABLE_TEST_FIXTURE", } +func TestCheckEnvironmentFlag(t *testing.T) { + tests := []struct { + name string + nilFlags bool + declareFlag bool + args []string + wantError bool + }{ + {name: "nil flagset", nilFlags: true}, + {name: "flag absent"}, + {name: "not changed", declareFlag: true}, + {name: "changed with name", declareFlag: true, args: []string{"--environment", "selected"}}, + {name: "changed with empty value", declareFlag: true, args: []string{"--environment", ""}, wantError: true}, + {name: "changed with whitespace", declareFlag: true, args: []string{"--environment", " "}, wantError: true}, + {name: "short flag with name", declareFlag: true, args: []string{"-e", "selected"}}, + {name: "short flag with empty value", declareFlag: true, args: []string{"-e", ""}, wantError: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var flags *pflag.FlagSet + if !tt.nilFlags { + flags = pflag.NewFlagSet(tt.name, pflag.ContinueOnError) + if tt.declareFlag { + flags.StringP("environment", "e", "", "") + } + require.NoError(t, flags.Parse(tt.args)) + } + + err := CheckEnvironmentFlag(flags) + + if !tt.wantError { + require.NoError(t, err) + return + } + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.Equal(t, azdext.LocalErrorCategoryValidation, localErr.Category) + require.Equal(t, exterrors.CodeInvalidParameter, localErr.Code) + require.Equal(t, "the --environment flag was set to an empty value", localErr.Message) + require.Equal(t, + "pass an environment name with --environment, or omit the flag to use the default environment", + localErr.Suggestion, + ) + }) + } +} + func TestIsEnabledValidation(t *testing.T) { tests := []struct { name string @@ -334,6 +385,7 @@ func TestOnlyCanonicalEnvironmentKey(t *testing.T) { t.Setenv(fixture.EnvVar, "") aliases := map[string]string{ "AZD_FOUNDRY_EXT_ENABLE_ALL": "true", + "AZD_ALPHA_ENABLE_AGENTS_VOICE": "true", "AZD_ALPHA_ENABLE_FOUNDRY_FIXTURE": "true", "AZD_ALPHA_ENABLE_FOUNDRY.FIXTURE": "true", "AZD_FOUNDRY_ENABLE_TEST_FIXTURE": "true", @@ -342,21 +394,26 @@ func TestOnlyCanonicalEnvironmentKey(t *testing.T) { "AZD_FOUNDRY_EXT_ENABLE_MODELS_OTHER": "true", "AZD_FOUNDRY_EXT_ENABLE_TEST_FIXTURE_": "true", } - for name, value := range aliases { - t.Setenv(name, value) - } - client := newMockClient(t) - client.expectSources( - alphaResponse(t, "off", "off"), - projectWithServices(&azdext.ServiceConfig{Host: "containerapp", Environment: maps.Clone(aliases)}), - aliases, - "", - ) + for _, feature := range append(All(), fixture) { + t.Run(feature.ID, func(t *testing.T) { + t.Setenv(feature.EnvVar, "") + for name, value := range aliases { + t.Setenv(name, value) + } + client := newMockClient(t) + client.expectSources( + alphaResponse(t, "off", "on"), + projectWithServices(&azdext.ServiceConfig{Host: "containerapp", Environment: maps.Clone(aliases)}), + aliases, + "selected", + ) - enabled, err := isEnabled(t.Context(), client, fixture, "") + enabled, err := isEnabled(t.Context(), client, feature, "selected") - require.NoError(t, err) - require.False(t, enabled) + require.NoError(t, err) + require.False(t, enabled) + }) + } } func TestEnvironmentSelection(t *testing.T) { From 0a6c4a2b1d1362d677c74e92286c35e224a10a69 Mon Sep 17 00:00:00 2001 From: Hui Miao Date: Thu, 22 Oct 2026 10:19:13 +0800 Subject: [PATCH 7/7] docs: match the spec to the pinned azd v1.37.0 --- .../azure.ai.agents/docs/specs/preview-feature-flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md index edbfaa4bed1..f332e1a9645 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md +++ b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md @@ -29,7 +29,7 @@ Dependency direction is `internal/cmd` or `internal/project` → `internal/featu The package belongs to the `azureaiagent` module and is imported as `azureaiagent/internal/featureflags`. Go's `internal` boundary excludes other extension modules. It requires neither a new module nor a root-module replacement or dependency upgrade to expose the helper. -The host-sentinel compatibility tests import the pinned azd environment packages. Their transitive dependencies require indirect entries for `heredoc/v2`, `azblob`, and `azfile` in this module. The azd requirement stays at `v1.35.1`. +The host-sentinel compatibility tests import the pinned azd environment packages. Their transitive dependencies require indirect entries for `heredoc/v2`, `azblob`, and `azfile` in this module. The azd requirement is `v1.37.0`. Keep validation, reads, and small evaluation helpers together in one implementation file. Splitting them into a loader framework or generic source abstraction would add indirection without another implementation to support. The registry is a plain list of declarations, not a plugin system.