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..f332e1a9645 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/docs/specs/preview-feature-flags.md @@ -0,0 +1,140 @@ +# Agents extension preview feature flags + + + +## Status and goal + +- **Component:** `cli/azd/extensions/azure.ai.agents` +- **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 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 + +```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 +│ ├── registry.go # Every declared feature and All +│ └── registry_test.go # Declaration rules +└── 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 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. + +## API and caller contract + +```go +type Feature struct { + ID string + EnvVar string + Description string +} + +func CheckEnvironmentFlag(flags *pflag.FlagSet) error + +func IsEnabled( + ctx context.Context, + client *azdext.AzdClient, + feature Feature, + environmentName string, +) (bool, error) + +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. + +| 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 +if err := featureflags.CheckEnvironmentFlag(cmd.Flags()); err != nil { + return err +} +enabled, err := featureflags.IsEnabled( + extCtx.Context(), + azdClient, + featureflags.Voice, + extCtx.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 + +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` | + +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 + +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 + +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 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. + +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 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 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 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/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/azure.ai.agents/internal/featureflags/featureflags.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go new file mode 100644 index 00000000000..f6a3eb15d83 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags.go @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// cspell:ignore featureflags + +// Package featureflags resolves agents extension preview 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" + "github.com/spf13/pflag" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "azureaiagent/internal/exterrors" +) + +// Feature declares one extension opt-in. +type Feature struct { + // ID is the user config key under alpha. + 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 ( + alphaKey = "alpha" + enabledValue = "on" +) + +// 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") +) + +type azdClient interface { + Project() azdext.ProjectServiceClient + Environment() azdext.EnvironmentServiceClient + 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 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, + 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 counts independently; 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 "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}) + value, ok := userConfig.GetString(alphaKey + "." + id) + return ok && strings.EqualFold(value, enabledValue) +} + +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/azure.ai.agents/internal/featureflags/featureflags_test.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go new file mode 100644 index 00000000000..9fdb0fc5bf7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/featureflags_test.go @@ -0,0 +1,821 @@ +// 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/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{ + ID: "foundry.fixture", + 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 + 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, path == "feature" && tt.want, enabled) + }) + } + } +} + +func TestGlobalConfigDoesNotEnableAgentsFeatures(t *testing.T) { + 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{ + "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) + }) + } + } +} + +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_AGENTS_VOICE": "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 _, 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, feature, "selected") + + 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", "off") + 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", "off"), 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) +} 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) +}