diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 6bd1ce25540..cb9fbee2b3c 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -39,9 +39,11 @@ words: - CLIENTSECRET - curr - dataagent + - envkey - exterrors - helloworld - hostedagent + - hostedagents - kval - logstream - mcpservertoolalwaysrequireapprovalmode @@ -56,5 +58,18 @@ words: - protocolversionrecord - Qdrant - Toolsets + - underscoped - Vnext - webp + # Doctor / next-step terms + - nextstep + - nextsteps + - undeployed + - unredacted + - UNKN + - inlines + - Remediations + - remediations + - uppercases + - parseable + - azd's diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 40b835c9590..c30a522d367 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -30,6 +30,8 @@ require ( require github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817 +require golang.org/x/term v0.41.0 + require ( dario.cat/mergo v1.0.2 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect @@ -110,7 +112,6 @@ require ( golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor.go new file mode 100644 index 00000000000..f66bb414b15 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor.go @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "azureaiagent/internal/cmd/doctor" + "azureaiagent/internal/cmd/nextstep" + "azureaiagent/internal/pkg/paths" + "azureaiagent/internal/version" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// doctorFlags are the Cobra-bound flags for `azd ai agent doctor`. +type doctorFlags struct { + localOnly bool + unredacted bool +} + +func newDoctorCommand() *cobra.Command { + flags := &doctorFlags{} + + cmd := &cobra.Command{ + Use: "doctor", + Short: "Diagnose problems with an azd ai agent project.", + Long: `Diagnose problems with an azd ai agent project. + +Runs a sequence of local and remote checks against the current azd project, +reporting on each one and (when all checks pass) suggesting the next +command to run. Use this when you have lost terminal context or hit a +confusing error and want a complete picture of the project's state. + +Exit codes: + 0 — at least one check passed and no checks failed + 1 — any check failed + 2 — all checks were skipped (e.g. preconditions unmet)`, + Example: ` # Run the full check suite + azd ai agent doctor`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + logCleanup := setupDebugLogging(cmd.Flags()) + defer logCleanup() + + // `--debug` (persistent root flag) also toggles the verbose per-check + // detail block in the doctor report. + debug := isDebug(cmd.Flags()) + + // Let `local.grpc-extension` report client creation failures so + // downstream checks can skip instead of duplicating the error. + azdClient, clientErr := azdext.NewAzdClient() + if azdClient != nil { + defer azdClient.Close() + } + + deps := doctor.Dependencies{ + AzdClient: azdClient, + AzdClientErr: clientErr, + ExtensionVersion: version.Version, + AgentAPIVersion: DefaultAgentAPIVersion, + } + + opts := doctor.Options{ + LocalOnly: flags.localOnly, + Unredacted: flags.unredacted, + } + + report, err := runAndRenderDoctorText(ctx, deps, opts, azdClient, os.Stdout, debug) + if err != nil { + return err + } + + // Use os.Exit to preserve doctor's 0/1/2 exit-code contract; + // Cobra/azdext would otherwise collapse all errors to 1. + // os.Exit skips defers, so do not add cleanup-critical defers here. + code := doctor.ExitCode(report) + if code == 0 { + return nil + } + os.Exit(code) + return nil // unreachable + }, + } + + cmd.Flags().BoolVar( + &flags.localOnly, "local-only", false, + "Skip remote (network-dependent) checks. "+ + "Useful when offline, behind a proxy, or for a fast local triage.", + ) + cmd.Flags().BoolVar( + &flags.unredacted, "unredacted", false, + "Show raw principal IDs, scope ARNs, and UPNs in the report.", + ) + + return cmd +} + +// runAndRenderDoctorText streams human-readable output as checks complete. +// `debug` switches between the default concise rendering and the verbose +// per-check Message/Suggestion/Links block. +func runAndRenderDoctorText( + ctx context.Context, + deps doctor.Dependencies, + opts doctor.Options, + azdClient *azdext.AzdClient, + w io.Writer, + debug bool, +) (doctor.Report, error) { + renderer := newDoctorRenderer(w, debug) + if err := renderer.writeHeader(); err != nil { + return doctor.Report{}, err + } + + report, trailing, err := runDoctorWithObserver( + ctx, + deps, + opts, + azdClient, + func(result doctor.Result) error { + return renderer.writeCheck(result) + }, + ) + if err != nil { + return report, err + } + + showNext := len(trailing) > 0 && writerIsTerminal(w) + if err := renderer.writeFooter(report, trailing, showNext); err != nil { + return report, err + } + return report, nil +} + +func runDoctorWithObserver( + ctx context.Context, + deps doctor.Dependencies, + opts doctor.Options, + azdClient *azdext.AzdClient, + observer doctor.ResultObserver, +) (doctor.Report, []nextstep.Suggestion, error) { + // Keep local checks first so remote checks can inspect their prior + // results for skip-cascade decisions. + checks := append(doctor.NewLocalChecks(deps), doctor.NewRemoteChecks(deps)...) + runner := doctor.Runner{Checks: checks} + report, err := runner.RunWithObserver(ctx, opts, observer) + if err != nil { + return report, nil, err + } + + // Show trailing Next: only on clean reports; otherwise it competes with + // the failing check's remediation. + if doctor.ExitCode(report) != 0 { + return report, nil, nil + } + + trailing := resolveDoctorTrailing(ctx, azdClient) + return report, trailing, nil +} + +// resolveDoctorTrailing returns the doctor's trailing Next block, or nil on +// error. It chooses deployed-agent suggestions when any service is deployed; +// otherwise it reuses the post-init guidance. +func resolveDoctorTrailing(ctx context.Context, azdClient *azdext.AzdClient) []nextstep.Suggestion { + if azdClient == nil { + return nil + } + + state, _ := nextstep.AssembleStateFromSource(ctx, nextstep.NewSource(azdClient)) + if len(state.Services) == 0 { + // Avoid repeating the missing-service guidance already reported by + // `local.agent-service-detected`. + return nil + } + + if anyServiceDeployed(state.Services) { + // Filter to deployed services so the generated invoke/show commands + // stay copy-paste correct. + return nextstep.ResolveAfterDeploy( + filterDeployedServices(state), + doctorCachedPayload(ctx, azdClient), + doctorReadmeExists(ctx, azdClient), + ) + } + + return nextstep.ResolveAfterInit(state) +} + +func anyServiceDeployed(services []nextstep.ServiceState) bool { + for _, s := range services { + if s.IsDeployed { + return true + } + } + return false +} + +// filterDeployedServices returns a shallow clone with only deployed services. +func filterDeployedServices(state *nextstep.State) *nextstep.State { + if state == nil { + return nil + } + clone := *state + clone.Services = make([]nextstep.ServiceState, 0, len(state.Services)) + for _, s := range state.Services { + if s.IsDeployed { + clone.Services = append(clone.Services, s) + } + } + return &clone +} + +// doctorCachedPayload returns a remote-cache lookup closure for ResolveAfterDeploy. +// It returns "" on failure and tries deployed Foundry agent names before +// falling back to azure.yaml service names. +func doctorCachedPayload(ctx context.Context, azdClient *azdext.AzdClient) func(string) string { + var envName string + if azdClient != nil { + if envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil && + envResp != nil && envResp.Environment != nil { + envName = envResp.Environment.Name + } + } + + return func(serviceName string) string { + if azdClient == nil || serviceName == "" { + return "" + } + configPath, err := resolveConfigPath(ctx, azdClient) + if err != nil { + return "" + } + configDir := filepath.Dir(configPath) + + if envName != "" { + nameKey := fmt.Sprintf("AGENT_%s_NAME", toServiceKey(serviceName)) + if v, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: nameKey, + }); err == nil && v != nil && v.Value != "" && v.Value != serviceName { + if spec, err := nextstep.ReadCachedOpenAPISpec(configDir, v.Value, "remote"); err == nil { + if payload := nextstep.ExtractInvokeExample(spec); payload != "" { + return payload + } + } + } + } + + spec, err := nextstep.ReadCachedOpenAPISpec(configDir, serviceName, "remote") + if err != nil { + return "" + } + return nextstep.ExtractInvokeExample(spec) + } +} + +// doctorReadmeExists returns a readmeExists closure for ResolveAfterDeploy. +// Only canonical "README.md" casing is checked to match rendered guidance. +func doctorReadmeExists(ctx context.Context, azdClient *azdext.AzdClient) func(string) bool { + projectRoot := resolveProjectPath(ctx, azdClient) + return func(relativePath string) bool { + if projectRoot == "" { + return false + } + readmePath, err := paths.JoinAllowRoot(projectRoot, relativePath, "README.md") + if err != nil { + return false + } + _, err = os.Stat(readmePath) + return err == nil + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_identity_roles.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_identity_roles.go new file mode 100644 index 00000000000..800284b421c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_identity_roles.go @@ -0,0 +1,655 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// agentIdentityProbeTimeout caps each per-agent principal-ID fetch. +// Matches the agent-status probe's timeout (6 s) because the same +// `GetAgentVersion` endpoint serves both — a project that's +// returning principal IDs within budget for check 11 will do the +// same here. Held shorter than the overall check budget so a single +// stalled agent doesn't drag the doctor past the design's per-check +// 6 s ceiling. +const agentIdentityProbeTimeout = 6 * time.Second + +// agentIdentityConcurrency bounds the per-agent fan-out used to +// fetch principal IDs in parallel. Matches probeConcurrency from +// the agent-status check; same rate-limit reasoning applies (Foundry +// per-token rate cap on the agents endpoint). +const agentIdentityConcurrency = 4 + +// agentIdentityClass values bucket a single agent's per-scope role +// inventory into a coarse class the aggregate folder consumes. +// Ordering used by `agentIdentityClassRank` is encoded by the int +// values: higher = worse for the aggregate. +const ( + agentIdentityClassFine = "fine" // project + (account|RG) → pass condition met + agentIdentityClassUnderscoped = "underscoped" // assignments somewhere but pass condition unmet + agentIdentityClassEmpty = "empty" // zero assignments anywhere reachable + agentIdentityClassUnknown = "unknown" // probe error — principal fetch failed +) + +// agentIdentityClassRank gives a strict ordering so the aggregate +// classifier can pick the dominant class without ambiguity. Higher +// values win. +var agentIdentityClassRank = map[string]int{ + agentIdentityClassFine: 0, + agentIdentityClassUnknown: 1, + agentIdentityClassUnderscoped: 2, + agentIdentityClassEmpty: 3, +} + +// agentIdentityRoleEntry is the unit the per-agent classifier +// produces. Surfaced verbatim under `Details["agents"]` for JSON +// consumers; aggregate Message picks the worst-class agent's name +// for headline rendering. +type agentIdentityRoleEntry struct { + AgentName string `json:"agentName"` + AgentVersion string `json:"agentVersion,omitempty"` + PrincipalID string `json:"principalId,omitempty"` + ProjectRoles []string `json:"projectRoles"` + AccountRoles []string `json:"accountRoles"` + RGRoles []string `json:"resourceGroupRoles"` + + // Errors per scope. nil = success (including legitimate empty + // list); non-nil = probe failure (the listing is unknown). + ProjectErr string `json:"projectErr,omitempty"` + AccountErr string `json:"accountErr,omitempty"` + RGErr string `json:"resourceGroupErr,omitempty"` + + Class string `json:"class"` + Detail string `json:"detail"` +} + +// agentIdentityProbeResult is the outcome of a single +// GetAgentVersion call used purely to extract +// `instance_identity.principal_id`. Distinct from +// agentStatusProbeResult because callers care about a different +// field (PrincipalID vs Status); keeping the type narrow protects +// against future drift. +type agentIdentityProbeResult struct { + PrincipalID string + StatusCode int + Err error +} + +// newCheckAgentIdentityRoles produces Check +// `remote.agent-identity-roles`. For each agent in the prior +// `remote.agent-status` Details that was classified Active, the +// check fetches the agent's `instance_identity.principal_id` and +// lists role assignments at three scopes (project, account, RG). +// Per-agent classification: +// +// - fine — project ≥ 1 and (account ≥ 1 OR RG ≥ 1) → +// contributes to an aggregate INFO. Pass condition per design. +// - underscoped — assignments somewhere but pass condition unmet +// (e.g., project only). Aggregate folds onto WARN. +// - empty — zero assignments anywhere reachable. Aggregate +// folds onto FAIL. +// - unknown — probe error during principal fetch or listing. +// Single-occurrence on its own is rendered as WARN; aggregated +// with empty entries it does not lower the FAIL severity. +// +// Aggregate rules: +// +// - All "fine" → INFO (informational role listing). +// - Any "empty" → FAIL (zero-roles agent is the +// smoking-gun for "every tool call 403s"). +// - Worst is "underscoped" → WARN. +// - Worst is "unknown" → WARN (probe couldn't classify). +// +// Skip cascade: +// - `remote.agent-status` must Pass (per the design, "for each +// active agent found in check 11"). +// - `local.environment-selected`, `local.agent-service-detected`, +// `remote.auth`, `remote.foundry-endpoint` — same precondition +// chain as `remote.agent-status`; surface a single Skip rather +// than re-validating each. +func newCheckAgentIdentityRoles(deps Dependencies) Check { + apiVersion := deps.AgentAPIVersion + return Check{ + ID: "remote.agent-identity-roles", + Name: "Agent identity role assignments", + Remote: true, + Fn: func(ctx context.Context, opts Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable.", + } + } + if priorBlocked(prior, "local.environment-selected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no azd environment is selected " + + "(see check `local.environment-selected`).", + } + } + if priorBlocked(prior, "local.agent-service-detected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no `azure.ai.agent` service in " + + "azure.yaml (see check " + + "`local.agent-service-detected`).", + } + } + if priorBlocked(prior, "remote.auth") { + return Result{ + Status: StatusSkip, + Message: "skipped: auth probe did not succeed " + + "(see check `remote.auth`).", + } + } + if priorBlocked(prior, "remote.foundry-endpoint") { + return Result{ + Status: StatusSkip, + Message: "skipped: Foundry endpoint did not respond " + + "(see check `remote.foundry-endpoint`).", + } + } + if !priorPassed(prior, "remote.agent-status") { + return Result{ + Status: StatusSkip, + Message: "skipped: agent status check did not pass " + + "(see check `remote.agent-status`).", + } + } + endpoint := readProjectEndpoint(prior) + if endpoint == "" { + return Result{ + Status: StatusSkip, + Message: "skipped: upstream check did not surface " + + "AZURE_AI_PROJECT_ENDPOINT in its Details.", + } + } + if apiVersion == "" { + return Result{ + Status: StatusSkip, + Message: "skipped: doctor wiring did not provide an " + + "agent API version for the probe.", + } + } + + actives := readActiveAgents(prior) + if len(actives) == 0 { + return Result{ + Status: StatusSkip, + Message: "skipped: no active agents reported by " + + "`remote.agent-status`.", + } + } + + projectResourceID := "" + var prErr error + if deps.readProjectResourceIDFn != nil { + projectResourceID, prErr = deps.readProjectResourceIDFn(ctx, deps.AzdClient) + } else { + projectResourceID, prErr = readProjectResourceID(ctx, deps.AzdClient) + } + if prErr != nil || projectResourceID == "" { + return Result{ + Status: StatusSkip, + Message: "skipped: AZURE_AI_PROJECT_ID is unset " + + "(needed to scope role-assignment listing).", + } + } + + principalProbe := deps.probeAgentPrincipal + if principalProbe == nil { + principalProbe = makeRealProbeAgentPrincipal(apiVersion) + } + + principals := fetchAllAgentPrincipals( + ctx, actives, endpoint, principalProbe) + + query := deps.queryAgentIdentityRoles + if query == nil { + query = project.QueryAgentIdentityRoles + } + + result, err := query(ctx, deps.AzdClient, projectResourceID, principals) + if err != nil { + if errors.Is(err, project.ErrInvalidProjectResourceID) { + return Result{ + Status: StatusSkip, + Message: "skipped: AZURE_AI_PROJECT_ID is " + + "malformed (cannot derive role-assignment scopes).", + } + } + return Result{ + Status: StatusWarn, + Message: "could not list agent identity roles: " + + firstLine(sanitizeScopeARNs(err.Error())), + Suggestion: "Re-run `azd ai agent doctor` once the " + + "transient failure clears.", + } + } + + entries := buildAgentIdentityRoleEntries(actives, result, opts.Unredacted) + return classifyAgentIdentityRolesAggregate(entries, result.Scopes, opts.Unredacted) + }, + } +} + +// activeAgentMeta is the per-agent triple the check fans out across. +// AgentVersion is preserved verbatim from the prior +// `remote.agent-status` entry so the Detail rendering shows the +// same version string the user already saw on the previous check. +type activeAgentMeta struct { + Service string + AgentName string + AgentVersion string +} + +// readActiveAgents pulls the agent name/version triples for active +// agents out of the upstream `remote.agent-status` Details. Returns +// nil if the Details are missing or the wrong shape; the caller +// folds that into a Skip rather than guessing. +// +// Only entries with Classification `active` are returned — the +// design explicitly scopes this check to active agents, and feeding +// a Creating/Failed agent into a role-listing probe would fail with +// confusing "agent has no identity yet" errors. +func readActiveAgents(prior []Result) []activeAgentMeta { + for _, p := range prior { + if p.ID != "remote.agent-status" { + continue + } + raw, ok := p.Details["services"] + if !ok { + return nil + } + entries, ok := raw.([]agentStatusEntry) + if !ok { + return nil + } + out := make([]activeAgentMeta, 0, len(entries)) + for _, e := range entries { + if e.Classification != agentClassActive { + continue + } + if e.AgentName == "" { + continue + } + out = append(out, activeAgentMeta{ + Service: e.Service, + AgentName: e.AgentName, + AgentVersion: e.AgentVersion, + }) + } + return out + } + return nil +} + +// fetchAllAgentPrincipals fans out principal-ID fetches with bounded +// concurrency. Order in the returned slice mirrors `actives` so the +// downstream Details rendering is deterministic. +func fetchAllAgentPrincipals( + ctx context.Context, + actives []activeAgentMeta, + endpoint string, + probe func(context.Context, string, string, string) agentIdentityProbeResult, +) []project.AgentPrincipal { + out := make([]project.AgentPrincipal, len(actives)) + sem := make(chan struct{}, agentIdentityConcurrency) + var wg sync.WaitGroup + for i, a := range actives { + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() + probeCtx, cancel := context.WithTimeout(ctx, agentIdentityProbeTimeout) + defer cancel() + res := probe(probeCtx, endpoint, a.AgentName, a.AgentVersion) + out[i] = project.AgentPrincipal{ + AgentName: a.AgentName, + AgentVersion: a.AgentVersion, + PrincipalID: res.PrincipalID, + } + }) + } + wg.Wait() + return out +} + +// buildAgentIdentityRoleEntries folds the project.QueryAgentIdentityRoles +// output into the per-agent classification structs the aggregate +// classifier consumes. The function is total — every input agent +// produces an output entry — so the aggregate can rely on +// `len(entries) == len(actives)`. +func buildAgentIdentityRoleEntries( + actives []activeAgentMeta, + res *project.AgentIdentityRolesResult, + unredacted bool, +) []agentIdentityRoleEntry { + byName := make(map[string]project.AgentIdentityRolesEntry, len(res.Entries)) + for _, e := range res.Entries { + byName[e.AgentName] = e + } + + out := make([]agentIdentityRoleEntry, 0, len(actives)) + for _, a := range actives { + entry := agentIdentityRoleEntry{ + AgentName: a.AgentName, + AgentVersion: a.AgentVersion, + } + qe, ok := byName[a.AgentName] + if !ok { + entry.Class = agentIdentityClassUnknown + entry.Detail = fmt.Sprintf( + "agent %q: role-assignment listing did not return.", + a.AgentName) + out = append(out, entry) + continue + } + entry.PrincipalID = redactID(qe.PrincipalID, unredacted) + if qe.PrincipalID == "" { + entry.Class = agentIdentityClassUnknown + entry.Detail = fmt.Sprintf( + "agent %q: could not resolve managed-identity "+ + "principal ID from Foundry.", + a.AgentName) + out = append(out, entry) + continue + } + + entry.ProjectRoles = qe.ProjectScope.Roles + entry.AccountRoles = qe.AccountScope.Roles + entry.RGRoles = qe.RGScope.Roles + if qe.ProjectScope.Err != nil { + entry.ProjectErr = redactErrorText(qe.ProjectScope.Err.Error(), unredacted) + } + if qe.AccountScope.Err != nil { + entry.AccountErr = redactErrorText(qe.AccountScope.Err.Error(), unredacted) + } + if qe.RGScope.Err != nil { + entry.RGErr = redactErrorText(qe.RGScope.Err.Error(), unredacted) + } + + entry.Class = classifyOneAgent(qe) + entry.Detail = describeOneAgent(qe) + out = append(out, entry) + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].AgentName < out[j].AgentName + }) + return out +} + +// redactErrorText scrubs ARM scope ARNs and bare GUIDs out of an +// error string and returns its first line. When unredacted is true, +// the error's first line is returned verbatim so operators running +// `--unredacted` see the raw backend response. Centralized here so +// every per-scope error path applies the same masking sequence. +func redactErrorText(s string, unredacted bool) string { + if unredacted { + return firstLine(s) + } + return firstLine(sanitizeScopeARNs(s)) +} + +// classifyOneAgent buckets a single agent's per-scope listing into +// one of fine / underscoped / empty / unknown. A scope counts as +// "covered" when its Err is nil and Roles is non-empty. The +// pass-condition (per design): project covered AND (account +// covered OR RG covered). +func classifyOneAgent(qe project.AgentIdentityRolesEntry) string { + projectCovered := qe.ProjectScope.Err == nil && len(qe.ProjectScope.Roles) > 0 + accountCovered := qe.AccountScope.Err == nil && len(qe.AccountScope.Roles) > 0 + rgCovered := qe.RGScope.Err == nil && len(qe.RGScope.Roles) > 0 + + // All three probes errored — we can't classify. + if qe.ProjectScope.Err != nil && qe.AccountScope.Err != nil && qe.RGScope.Err != nil { + return agentIdentityClassUnknown + } + + anyCovered := projectCovered || accountCovered || rgCovered + if !anyCovered { + return agentIdentityClassEmpty + } + if projectCovered && (accountCovered || rgCovered) { + return agentIdentityClassFine + } + return agentIdentityClassUnderscoped +} + +// describeOneAgent renders the one-line per-agent Detail. Format: +// +// : project=N, account=M, resource-group=K +// +// with `?` for probe-error scopes. Designed to fit on one line of an +// 80-col terminal even with 4-char wide role names plus the lead +// `: ` prefix. +func describeOneAgent(qe project.AgentIdentityRolesEntry) string { + return fmt.Sprintf( + "%s: project=%s, account=%s, resource-group=%s", + qe.AgentName, + formatScopeCount(qe.ProjectScope), + formatScopeCount(qe.AccountScope), + formatScopeCount(qe.RGScope), + ) +} + +// formatScopeCount renders one scope's count for describeOneAgent. +// Returns the literal "?" when the per-scope probe errored — the +// row tells the user the listing is incomplete without needing to +// open Details. +func formatScopeCount(sr project.AgentScopeRoles) string { + if sr.Err != nil { + return "?" + } + return fmt.Sprintf("%d", len(sr.Roles)) +} + +// classifyAgentIdentityRolesAggregate folds the per-agent entries +// into a single doctor Result. The aggregate Status is the worst +// per-agent Class's bucket; the Message picks the worst entry for +// the headline and the per-agent breakdown lands in Details. A +// remediation suggestion is attached only to FAIL and WARN +// classes — the INFO state has nothing actionable to offer. +// +// Empty entries (no active agents to enumerate) are folded into +// Skip upstream; if the function is somehow reached with an empty +// slice it produces a Skip rather than a Pass to avoid emitting an +// empty INFO line. +func classifyAgentIdentityRolesAggregate( + entries []agentIdentityRoleEntry, + scopes project.AgentIdentityScopes, + unredacted bool, +) Result { + if len(entries) == 0 { + return Result{ + Status: StatusSkip, + Message: "no active agents to enumerate.", + } + } + + worst := agentIdentityClassFine + for _, e := range entries { + if rankAgentIdentity(e.Class) > rankAgentIdentity(worst) { + worst = e.Class + } + } + + byClass := map[string]int{} + for _, e := range entries { + byClass[e.Class]++ + } + + details := map[string]any{ + "agents": entries, + "byClassification": byClass, + "scopes": map[string]string{ + "project": redactScope(scopes.Project, unredacted), + "account": redactScope(scopes.Account, unredacted), + "resource-group": redactScope(scopes.ResourceGroup, unredacted), + }, + } + + detailLines := func() []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Detail) + } + return out + } + + switch worst { + case agentIdentityClassFine: + return Result{ + Status: StatusInfo, + Message: fmt.Sprintf( + "%d of %d agents have role assignments at the "+ + "project scope plus at least one of "+ + "account/resource-group scope.", + byClass[agentIdentityClassFine], len(entries)), + Details: details, + Suggestion: "Role assignments listed; no action needed. " + + "Use `azd ai agent doctor --output json` for the " + + "machine-readable per-agent breakdown.\n " + + strings.Join(detailLines(), "\n "), + } + case agentIdentityClassUnknown: + // Pure-unknown aggregate: every agent had a probe failure. + // Surface as WARN with a re-run hint. + return Result{ + Status: StatusWarn, + Message: fmt.Sprintf( + "%d of %d agents: could not list role assignments.", + byClass[agentIdentityClassUnknown], len(entries)), + Details: details, + Suggestion: "Re-run `azd ai agent doctor` once the " + + "transient failure clears. Per-agent detail:\n " + + strings.Join(detailLines(), "\n "), + } + case agentIdentityClassUnderscoped: + // At least one agent is underscoped (assignments exist + // somewhere but pass condition unmet). + return Result{ + Status: StatusWarn, + Message: fmt.Sprintf( + "%d of %d agents have under-privileged role assignments.", + byClass[agentIdentityClassUnderscoped], len(entries)), + Details: details, + Suggestion: "Agents may not have permission to access " + + "project / account resources at runtime. Grant " + + "a role on the missing scope:\n " + + "az role assignment create --assignee " + + "--role --scope \n" + + "Per-agent detail:\n " + + strings.Join(detailLines(), "\n "), + } + case agentIdentityClassEmpty: + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d of %d agents have zero role assignments at any "+ + "reachable scope.", + byClass[agentIdentityClassEmpty], len(entries)), + Details: details, + Suggestion: "Agents will likely 403 on every tool call. " + + "Grant Cognitive Services User (or stronger) on " + + "the project scope:\n " + + "az role assignment create --assignee " + + "--role \"Cognitive Services User\" --scope " + + "\nPer-agent detail:\n " + + strings.Join(detailLines(), "\n "), + } + default: + // Defensive: an unrecognized class collapses to WARN with + // the raw class string surfaced for diagnostic purposes. + return Result{ + Status: StatusWarn, + Message: fmt.Sprintf( + "unrecognized aggregate class %q.", worst), + Details: details, + } + } +} + +func rankAgentIdentity(class string) int { + if r, ok := agentIdentityClassRank[class]; ok { + return r + } + return -1 +} + +// makeRealProbeAgentPrincipal returns the production closure used +// to fetch one agent's `instance_identity.principal_id`. Mirrors +// makeRealProbeAgentStatus from checks_agent_status.go (same +// credential and SDK client; different response field consumed) +// so a Pass here matches what the runtime invoke flow would see. +func makeRealProbeAgentPrincipal( + apiVersion string, +) func(context.Context, string, string, string) agentIdentityProbeResult { + return func( + ctx context.Context, + endpoint, agentName, agentVersion string, + ) agentIdentityProbeResult { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return agentIdentityProbeResult{ + Err: fmt.Errorf("create credential: %w", err), + } + } + client := agent_api.NewAgentClient(endpoint, cred) + v, err := client.GetAgentVersion( + ctx, agentName, agentVersion, apiVersion) + if err != nil { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + return agentIdentityProbeResult{ + StatusCode: respErr.StatusCode, + Err: err, + } + } + return agentIdentityProbeResult{Err: err} + } + if v == nil { + return agentIdentityProbeResult{ + Err: errors.New("GetAgentVersion returned nil"), + } + } + if v.InstanceIdentity == nil { + return agentIdentityProbeResult{ + StatusCode: 200, + Err: fmt.Errorf("agent %q has no instance_identity", + agentName), + } + } + return agentIdentityProbeResult{ + StatusCode: 200, + PrincipalID: v.InstanceIdentity.PrincipalID, + } + } +} + +// priorPassed reports whether a prior check with the given ID +// produced StatusPass. False both for "check not in slice" and +// "check present but didn't pass" — callers handle the two +// outcomes the same way (Skip). +func priorPassed(prior []Result, id string) bool { + for _, p := range prior { + if p.ID == id { + return p.Status == StatusPass + } + } + return false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_identity_roles_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_identity_roles_test.go new file mode 100644 index 00000000000..324d0e2481a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_identity_roles_test.go @@ -0,0 +1,562 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "strings" + "testing" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// agentIdentityPriorResults produces a complete prior-result slice +// satisfying every skip-cascade gate `remote.agent-identity-roles` +// declares. The `agentStatusEntries` slice is the production-shape +// listing the upstream `remote.agent-status` check surfaces under +// `Details["services"]`; only entries with Classification == "active" +// will be consumed by C12. +func agentIdentityPriorResults( + agentStatusEntries []agentStatusEntry, + endpoint string, +) []Result { + return []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass}, + {ID: "local.project-endpoint-set", Status: StatusPass, Details: map[string]any{ + "projectEndpoint": endpoint, + }}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusPass}, + {ID: "remote.agent-status", Status: StatusPass, Details: map[string]any{ + "services": agentStatusEntries, + }}, + } +} + +// runIdentityCheck wires deps with default test-friendly values and +// invokes the check. Tests that need to override a seam pass it in +// `deps`; defaults preserve the no-network contract. +func runIdentityCheck(t *testing.T, deps Dependencies, prior []Result) Result { + t.Helper() + if deps.AzdClient == nil { + deps.AzdClient = &azdext.AzdClient{} + } + if deps.AgentAPIVersion == "" { + deps.AgentAPIVersion = "2025-11-15-preview" + } + if deps.readProjectResourceIDFn == nil { + deps.readProjectResourceIDFn = func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub-1/resourceGroups/rg-1/providers/" + + "Microsoft.CognitiveServices/accounts/acc-1/projects/proj-1", nil + } + } + if deps.probeAgentPrincipal == nil { + // Default: every probe returns a deterministic principal ID + // derived from the agent name. Tests overriding need do so + // explicitly via deps.probeAgentPrincipal. + deps.probeAgentPrincipal = func(_ context.Context, _, name, _ string) agentIdentityProbeResult { + return agentIdentityProbeResult{ + StatusCode: 200, + PrincipalID: "principal-" + name, + } + } + } + c := newCheckAgentIdentityRoles(deps) + require.NotNil(t, c.Fn, "newCheckAgentIdentityRoles must return a non-nil Fn") + return c.Fn(t.Context(), Options{}, prior) +} + +// ---- Skip-cascade gates ---- + +func TestCheckAgentIdentityRoles_SkipsWhenAzdClientNil(t *testing.T) { + t.Parallel() + c := newCheckAgentIdentityRoles(Dependencies{}) + res := c.Fn(t.Context(), Options{}, nil) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "azd extension not reachable") +} + +func TestCheckAgentIdentityRoles_SkipsWhenAgentStatusNotPassed(t *testing.T) { + t.Parallel() + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusPass}, + {ID: "remote.agent-status", Status: StatusFail}, + } + res := runIdentityCheck(t, Dependencies{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "remote.agent-status") +} + +func TestCheckAgentIdentityRoles_SkipsWhenProjectEndpointMissing(t *testing.T) { + t.Parallel() + // Drop the project-endpoint Result's Details + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "") + res := runIdentityCheck(t, Dependencies{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "AZURE_AI_PROJECT_ENDPOINT") +} + +func TestCheckAgentIdentityRoles_SkipsWhenNoActiveAgents(t *testing.T) { + t.Parallel() + // Only Creating / Failed entries — no active ones. + prior := agentIdentityPriorResults( + []agentStatusEntry{ + {Service: "a", AgentName: "an", AgentVersion: "1", Classification: agentClassDeploying}, + {Service: "b", AgentName: "bn", AgentVersion: "1", Classification: agentClassFailed}, + }, + "https://example.local") + res := runIdentityCheck(t, Dependencies{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "active agents") +} + +func TestCheckAgentIdentityRoles_SkipsWhenAPIVersionEmpty(t *testing.T) { + t.Parallel() + // Bypass runIdentityCheck so AgentAPIVersion stays empty — + // runIdentityCheck would auto-populate it. Build deps with the + // minimum needed to clear the AzdClient nil guard. + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + // AgentAPIVersion deliberately empty + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub-1/resourceGroups/rg-1/providers/" + + "Microsoft.CognitiveServices/accounts/acc-1/projects/proj-1", nil + }, + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + res := newCheckAgentIdentityRoles(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "agent API version") +} + +func TestCheckAgentIdentityRoles_SkipsWhenProjectResourceIDUnset(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "", nil + }, + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + res := runIdentityCheck(t, deps, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "AZURE_AI_PROJECT_ID") +} + +func TestCheckAgentIdentityRoles_SkipsWhenProjectResourceIDMalformed(t *testing.T) { + t.Parallel() + deps := Dependencies{ + queryAgentIdentityRoles: func(_ context.Context, _ *azdext.AzdClient, _ string, _ []project.AgentPrincipal) (*project.AgentIdentityRolesResult, error) { + return nil, project.ErrInvalidProjectResourceID + }, + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + res := runIdentityCheck(t, deps, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "malformed") +} + +// ---- Aggregate classification ---- + +func makeQueryReturning(result *project.AgentIdentityRolesResult) func( + context.Context, *azdext.AzdClient, string, []project.AgentPrincipal, +) (*project.AgentIdentityRolesResult, error) { + return func(_ context.Context, _ *azdext.AzdClient, _ string, principals []project.AgentPrincipal) (*project.AgentIdentityRolesResult, error) { + // Echo the input principals' agent names through to entries + // when the test supplied a single-entry result keyed by name; + // otherwise return the canned result verbatim. + _ = principals + return result, nil + } +} + +func TestCheckAgentIdentityRoles_AggregateInfoWhenAllAgentsFine(t *testing.T) { + t.Parallel() + deps := Dependencies{ + queryAgentIdentityRoles: makeQueryReturning(&project.AgentIdentityRolesResult{ + Entries: []project.AgentIdentityRolesEntry{ + { + AgentName: "a", + PrincipalID: "principal-a", + ProjectScope: project.AgentScopeRoles{Scope: "project", Roles: []string{"Azure AI User"}}, + AccountScope: project.AgentScopeRoles{Scope: "account", Roles: []string{"Cognitive Services User"}}, + RGScope: project.AgentScopeRoles{Scope: "resource-group", Roles: []string{}}, + }, + }, + Scopes: project.AgentIdentityScopes{Project: "scope-p", Account: "scope-a", ResourceGroup: "scope-rg"}, + }), + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc-a", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + res := runIdentityCheck(t, deps, prior) + require.Equal(t, StatusInfo, res.Status) + require.Contains(t, res.Message, "1 of 1 agents") + require.Contains(t, res.Suggestion, "no action needed") +} + +func TestCheckAgentIdentityRoles_AggregateFailWhenAnyAgentEmpty(t *testing.T) { + t.Parallel() + deps := Dependencies{ + queryAgentIdentityRoles: makeQueryReturning(&project.AgentIdentityRolesResult{ + Entries: []project.AgentIdentityRolesEntry{ + { + AgentName: "a", + PrincipalID: "principal-a", + ProjectScope: project.AgentScopeRoles{Scope: "project", Roles: []string{"Azure AI User"}}, + AccountScope: project.AgentScopeRoles{Scope: "account", Roles: []string{}}, + RGScope: project.AgentScopeRoles{Scope: "resource-group", Roles: []string{}}, + }, + { + AgentName: "b", + PrincipalID: "principal-b", + ProjectScope: project.AgentScopeRoles{Scope: "project", Roles: []string{}}, + AccountScope: project.AgentScopeRoles{Scope: "account", Roles: []string{}}, + RGScope: project.AgentScopeRoles{Scope: "resource-group", Roles: []string{}}, + }, + }, + }), + } + prior := agentIdentityPriorResults( + []agentStatusEntry{ + {Service: "svc-a", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}, + {Service: "svc-b", AgentName: "b", AgentVersion: "1", Classification: agentClassActive}, + }, + "https://example.local") + res := runIdentityCheck(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "zero role assignments") + require.Contains(t, res.Suggestion, "az role assignment create") +} + +func TestCheckAgentIdentityRoles_AggregateWarnWhenAgentUnderscoped(t *testing.T) { + t.Parallel() + deps := Dependencies{ + queryAgentIdentityRoles: makeQueryReturning(&project.AgentIdentityRolesResult{ + Entries: []project.AgentIdentityRolesEntry{ + { + AgentName: "a", + PrincipalID: "principal-a", + // project covered but neither account nor RG — underscoped + ProjectScope: project.AgentScopeRoles{Scope: "project", Roles: []string{"Azure AI User"}}, + AccountScope: project.AgentScopeRoles{Scope: "account", Roles: []string{}}, + RGScope: project.AgentScopeRoles{Scope: "resource-group", Roles: []string{}}, + }, + }, + }), + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc-a", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + res := runIdentityCheck(t, deps, prior) + require.Equal(t, StatusWarn, res.Status) + require.Contains(t, res.Message, "under-privileged") +} + +func TestCheckAgentIdentityRoles_AggregateWarnOnQueryError(t *testing.T) { + t.Parallel() + deps := Dependencies{ + queryAgentIdentityRoles: func(_ context.Context, _ *azdext.AzdClient, _ string, _ []project.AgentPrincipal) (*project.AgentIdentityRolesResult, error) { + return nil, errors.New("ARM transient") + }, + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc-a", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + res := runIdentityCheck(t, deps, prior) + require.Equal(t, StatusWarn, res.Status) + require.Contains(t, res.Message, "ARM transient") +} + +// ---- Per-agent classifier ---- + +func TestClassifyOneAgent_FineWhenProjectPlusAccountOrRG(t *testing.T) { + t.Parallel() + cases := []struct { + name string + qe project.AgentIdentityRolesEntry + want string + }{ + { + name: "project+account → fine", + qe: project.AgentIdentityRolesEntry{ + ProjectScope: project.AgentScopeRoles{Roles: []string{"r"}}, + AccountScope: project.AgentScopeRoles{Roles: []string{"r"}}, + RGScope: project.AgentScopeRoles{Roles: []string{}}, + }, + want: agentIdentityClassFine, + }, + { + name: "project+RG → fine", + qe: project.AgentIdentityRolesEntry{ + ProjectScope: project.AgentScopeRoles{Roles: []string{"r"}}, + AccountScope: project.AgentScopeRoles{Roles: []string{}}, + RGScope: project.AgentScopeRoles{Roles: []string{"r"}}, + }, + want: agentIdentityClassFine, + }, + { + name: "project only → underscoped", + qe: project.AgentIdentityRolesEntry{ + ProjectScope: project.AgentScopeRoles{Roles: []string{"r"}}, + AccountScope: project.AgentScopeRoles{Roles: []string{}}, + RGScope: project.AgentScopeRoles{Roles: []string{}}, + }, + want: agentIdentityClassUnderscoped, + }, + { + name: "account only → underscoped (no project coverage)", + qe: project.AgentIdentityRolesEntry{ + ProjectScope: project.AgentScopeRoles{Roles: []string{}}, + AccountScope: project.AgentScopeRoles{Roles: []string{"r"}}, + RGScope: project.AgentScopeRoles{Roles: []string{}}, + }, + want: agentIdentityClassUnderscoped, + }, + { + name: "all empty → empty", + qe: project.AgentIdentityRolesEntry{ + ProjectScope: project.AgentScopeRoles{Roles: []string{}}, + AccountScope: project.AgentScopeRoles{Roles: []string{}}, + RGScope: project.AgentScopeRoles{Roles: []string{}}, + }, + want: agentIdentityClassEmpty, + }, + { + name: "all errored → unknown", + qe: project.AgentIdentityRolesEntry{ + ProjectScope: project.AgentScopeRoles{Err: errors.New("e")}, + AccountScope: project.AgentScopeRoles{Err: errors.New("e")}, + RGScope: project.AgentScopeRoles{Err: errors.New("e")}, + }, + want: agentIdentityClassUnknown, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := classifyOneAgent(tc.qe) + require.Equal(t, tc.want, got) + }) + } +} + +// ---- Detail formatting ---- + +func TestDescribeOneAgent_RendersScopeCounts(t *testing.T) { + t.Parallel() + qe := project.AgentIdentityRolesEntry{ + AgentName: "agent-x", + ProjectScope: project.AgentScopeRoles{Roles: []string{"a", "b"}}, + AccountScope: project.AgentScopeRoles{Roles: []string{}}, + RGScope: project.AgentScopeRoles{Err: errors.New("listing failed")}, + } + got := describeOneAgent(qe) + require.Equal(t, "agent-x: project=2, account=0, resource-group=?", got) +} + +// ---- Redaction ---- + +// TestCheckAgentIdentityRoles_RedactedDetailsDoNotLeakIdentifiers +// asserts the doctor's redaction contract: when Options.Unredacted +// is false (the default), Details must not surface raw principal IDs, +// raw ARM scope ARNs, or scope-bearing error strings. With +// Unredacted=true, the same identifiers must pass through verbatim +// so operators running `--unredacted` see what the backend returned. +func TestCheckAgentIdentityRoles_RedactedDetailsDoNotLeakIdentifiers(t *testing.T) { + t.Parallel() + rawPrincipal := "11111111-2222-3333-4444-555555555555" + rawProjectScope := "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/" + + "resourceGroups/rg-secret/providers/Microsoft.CognitiveServices/" + + "accounts/acc-secret/projects/proj-secret" + rawAccountScope := "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/" + + "resourceGroups/rg-secret/providers/Microsoft.CognitiveServices/" + + "accounts/acc-secret" + rawRGScope := "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/" + + "resourceGroups/rg-secret" + rawScopeBearingErr := errors.New("failed to list role assignments at " + + "scope " + rawProjectScope + ": forbidden") + canned := &project.AgentIdentityRolesResult{ + Entries: []project.AgentIdentityRolesEntry{ + { + AgentName: "a", + PrincipalID: rawPrincipal, + ProjectScope: project.AgentScopeRoles{Scope: "project", Err: rawScopeBearingErr}, + AccountScope: project.AgentScopeRoles{Scope: "account", Roles: []string{"r"}}, + RGScope: project.AgentScopeRoles{Scope: "resource-group", Roles: []string{"r"}}, + }, + }, + Scopes: project.AgentIdentityScopes{ + Project: rawProjectScope, + Account: rawAccountScope, + ResourceGroup: rawRGScope, + }, + } + deps := Dependencies{ + queryAgentIdentityRoles: makeQueryReturning(canned), + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc-a", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + + // Redacted (default). + res := runIdentityCheck(t, deps, prior) + // Serialize Details to a single string so the assertion catches + // leaks regardless of struct key path. + detailsRedacted := flattenDetails(res.Details) + require.NotContains(t, detailsRedacted, rawPrincipal, + "redacted Details must not contain raw principal ID") + require.NotContains(t, detailsRedacted, rawProjectScope, + "redacted Details must not contain raw project scope ARN") + require.NotContains(t, detailsRedacted, rawAccountScope, + "redacted Details must not contain raw account scope ARN") + require.NotContains(t, detailsRedacted, rawRGScope, + "redacted Details must not contain raw RG scope ARN") + require.Contains(t, detailsRedacted, "", + "redacted Details must contain the redacted placeholder") + + // Unredacted: same fixture, opts.Unredacted=true. + check := newCheckAgentIdentityRoles(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub-1/resourceGroups/rg-1/providers/" + + "Microsoft.CognitiveServices/accounts/acc-1/projects/proj-1", nil + }, + probeAgentPrincipal: func(_ context.Context, _, _, _ string) agentIdentityProbeResult { + return agentIdentityProbeResult{PrincipalID: rawPrincipal, StatusCode: 200} + }, + queryAgentIdentityRoles: makeQueryReturning(canned), + AgentAPIVersion: "2025-11-15-preview", + }) + resU := check.Fn(t.Context(), Options{Unredacted: true}, prior) + detailsUnredacted := flattenDetails(resU.Details) + require.Contains(t, detailsUnredacted, rawPrincipal, + "unredacted Details must contain raw principal ID") + require.Contains(t, detailsUnredacted, rawProjectScope, + "unredacted Details must contain raw project scope ARN") +} + +// flattenDetails walks the Details map and returns a single string +// suitable for substring assertions. Used by redaction tests so +// callers don't need to know the exact key path the check emits. +func flattenDetails(d map[string]any) string { + if d == nil { + return "" + } + var sb strings.Builder + for k, v := range d { + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(stringify(v)) + sb.WriteString("\n") + } + return sb.String() +} + +func stringify(v any) string { + switch t := v.(type) { + case string: + return t + case map[string]string: + var sb strings.Builder + for k, val := range t { + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(val) + sb.WriteString(";") + } + return sb.String() + case []agentIdentityRoleEntry: + var sb strings.Builder + for _, e := range t { + sb.WriteString(e.AgentName) + sb.WriteString(":") + sb.WriteString(e.PrincipalID) + sb.WriteString(";") + sb.WriteString(e.ProjectErr) + sb.WriteString(";") + sb.WriteString(e.AccountErr) + sb.WriteString(";") + sb.WriteString(e.RGErr) + sb.WriteString("|") + } + return sb.String() + default: + return "" + } +} + +// ---- Missing-principal degradation ---- + +func TestCheckAgentIdentityRoles_DegradesWhenPrincipalMissing(t *testing.T) { + t.Parallel() + // Build a Result where principal probe is "missing"; the query + // fake will surface that as an unknown-class entry. + deps := Dependencies{ + probeAgentPrincipal: func(_ context.Context, _, _, _ string) agentIdentityProbeResult { + return agentIdentityProbeResult{Err: errors.New("no identity")} + }, + queryAgentIdentityRoles: func(_ context.Context, _ *azdext.AzdClient, _ string, principals []project.AgentPrincipal) (*project.AgentIdentityRolesResult, error) { + // Echo missing-principal entries with all-error scopes + // to mirror what production QueryAgentIdentityRoles + // produces when PrincipalID == "". + entries := make([]project.AgentIdentityRolesEntry, 0, len(principals)) + for _, p := range principals { + entries = append(entries, project.AgentIdentityRolesEntry{ + AgentName: p.AgentName, + PrincipalID: "", + ProjectScope: project.AgentScopeRoles{Err: errors.New("principal ID unavailable")}, + AccountScope: project.AgentScopeRoles{Err: errors.New("principal ID unavailable")}, + RGScope: project.AgentScopeRoles{Err: errors.New("principal ID unavailable")}, + }) + } + return &project.AgentIdentityRolesResult{Entries: entries}, nil + }, + } + prior := agentIdentityPriorResults( + []agentStatusEntry{{Service: "svc-a", AgentName: "a", AgentVersion: "1", Classification: agentClassActive}}, + "https://example.local") + res := runIdentityCheck(t, deps, prior) + require.Equal(t, StatusWarn, res.Status) + require.True(t, strings.Contains(res.Message, "could not list") || strings.Contains(res.Message, "transient")) +} + +// ---- readActiveAgents filtering ---- + +func TestReadActiveAgents_FiltersToActiveOnly(t *testing.T) { + t.Parallel() + prior := []Result{ + {ID: "remote.agent-status", Status: StatusPass, Details: map[string]any{ + "services": []agentStatusEntry{ + {Service: "a", AgentName: "an", AgentVersion: "1", Classification: agentClassActive}, + {Service: "b", AgentName: "bn", AgentVersion: "1", Classification: agentClassFailed}, + {Service: "c", AgentName: "cn", AgentVersion: "1", Classification: agentClassDeploying}, + {Service: "d", AgentName: "", AgentVersion: "1", Classification: agentClassActive}, // missing name dropped + }, + }}, + } + got := readActiveAgents(prior) + require.Len(t, got, 1) + require.Equal(t, "an", got[0].AgentName) +} + +func TestReadActiveAgents_ReturnsNilWhenAgentStatusMissing(t *testing.T) { + t.Parallel() + got := readActiveAgents(nil) + require.Nil(t, got) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_status.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_status.go new file mode 100644 index 00000000000..9d6906f2b8c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_status.go @@ -0,0 +1,794 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// agentStatusProbeTimeout caps the per-service Foundry round trip. +// The check fans out one GET per hosted-agent service in azure.yaml +// concurrently with bounded worker pool (probeConcurrency); the +// per-probe ceiling is held shorter than the foundry-endpoint +// reachability probe (which only ever runs once). 6 s gives a +// stalled DNS / VPN time to surface as a clean timeout without +// making a 5-service project drag the whole doctor run past half a +// minute. +const agentStatusProbeTimeout = 6 * time.Second + +// probeConcurrency bounds the agent-status probe fan-out. The +// design spec calls for parallel probes ("fan out one GET per +// hosted-agent service") so wall-clock cost stays bounded by the +// slowest probe, not the sum. 4 workers balances responsiveness +// against the risk of overwhelming Foundry's per-token rate limit +// when a project declares dozens of services. +const probeConcurrency = 4 + +// agentStatusKindActive / Creating / Failed / Deleting are the +// canonical lifecycle values emitted by the Foundry agents service +// (vienna: +// `Contracts/V2/Generated/Agents/AgentVersionStatus.cs`). Matched +// case-insensitively because Foundry has historically been +// inconsistent about casing on similar fields (e.g., the run.go +// invocation flow normalizes status with `strings.EqualFold`). +const ( + agentStatusActive = "Active" + agentStatusCreating = "Creating" + agentStatusFailed = "Failed" + agentStatusDeleting = "Deleting" + agentStatusDeleted = "Deleted" +) + +// agentStatusProbeResult is the structured outcome of one +// `GetAgentVersion` call. statusCode is the HTTP code (0 when the +// request never reached Foundry — DNS, TLS, context timeout, etc.). +// status is the lifecycle string from the response body when the +// call returned a 200; empty in any other case. err is the raw +// transport / SDK error. +// +// We intentionally don't surface the full AgentVersionObject — +// the doctor only needs the lifecycle + HTTP status to classify. +// Keeping the surface narrow protects callers from coupling to the +// AgentVersionObject shape (which evolves with the Foundry API). +type agentStatusProbeResult struct { + statusCode int + status string + err error +} + +// agentStatusEntry captures the per-service outcome of the probe. +// It is the unit the aggregate-classifier consumes; we keep this +// internal so future evolution (e.g., adding role information) +// does not break callers that only need the lifecycle. The struct +// is also surfaced verbatim under `Details["services"]` so JSON +// consumers can iterate without re-parsing the human-readable +// Message. +type agentStatusEntry struct { + Service string `json:"service"` + AgentName string `json:"agentName,omitempty"` + AgentVersion string `json:"agentVersion,omitempty"` + Status string `json:"status,omitempty"` + HTTPStatus int `json:"httpStatus,omitempty"` + // Classification is the doctor-level bucket. Distinct from + // Status because the aggregate logic branches on this, not on + // the Foundry lifecycle name (which can grow without notice). + Classification string `json:"classification"` + // Detail is the per-service human-readable explanation. The + // aggregate Message picks the worst-classification's Detail + // when emitting a one-line summary; the full per-service list + // always lands in Details. + Detail string `json:"detail,omitempty"` +} + +// Per-service classifications. These map onto doctor.Status with +// the aggregate rules in `classifyAgentStatusAggregate`. The +// ordering used by `worseClassification` is encoded by the integer +// values: higher = worse. +const ( + agentClassActive = "active" + agentClassDeploying = "deploying" + agentClassFailed = "failed" + agentClassMissing = "missing" // 404 / agent not found + agentClassNotDeployed = "not-deployed" // AGENT__NAME absent + agentClassUnknown = "unknown" // unrecognized status string + agentClassTransientErr = "transient" // probe error (skipped, not failed) +) + +// agentClassRank gives a strict ordering for the aggregate +// classifier: the highest rank present drives the aggregate +// Status. Ties on `failed`/`missing` collapse to a single +// fail-class Suggestion ("see service-specific details below"). +// +// `active` is the floor; `transient` sits just above active so a +// run that mixes a Pass with a Skip-class entry surfaces the +// Skip-class entry's Suggestion but doesn't turn the whole check +// red (a transient probe failure is genuinely a Skip for that +// service). +var agentClassRank = map[string]int{ + agentClassActive: 0, + agentClassTransientErr: 1, + agentClassDeploying: 2, + agentClassNotDeployed: 3, + agentClassUnknown: 4, + agentClassMissing: 5, + agentClassFailed: 6, +} + +// newCheckAgentStatus produces Check `remote.agent-status`. For +// each hosted-agent service in azure.yaml it resolves the +// deployed agent name / version from the active azd environment +// (the values written by `service_target_agent.go` after a +// successful `azd deploy`) and probes Foundry's +// `GET /agents/{name}/versions/{version}` endpoint. +// +// Per-service classification is exhaustive: +// +// - Active → Pass for that service. +// - Creating → Warn (`monitor --follow` for live progress). +// - Failed → Fail (`monitor --follow` to read logs, NOT +// `azd deploy` — redeploying without understanding the failure +// just re-creates the same broken version). +// - 404 / Deleted → Fail (`azd deploy`). +// - AGENT__NAME absent, or NAME set but AGENT__VERSION +// absent → Fail (`azd deploy`) — the service is declared but +// has never been (fully) deployed; the user knows what to do. +// - Unrecognized status → Fail with the raw status string so +// the user can search for it; suggests inspecting the agent in +// the Foundry portal. +// - Probe error (network, 401, 403, 5xx, …) → service-scoped +// skip; aggregate classifier keeps it from failing the run if +// other services are healthy. +// +// Aggregate rule (see `classifyAgentStatusAggregate`): +// +// - All Active → Pass. +// - Worst class is `failed` / `missing` / `unknown` / `not-deployed` +// → Fail. +// - Worst class is `deploying` → Warn. +// - Worst class is `transient` and at least one Active → Pass +// (with a Message note that some services were skipped). +// - All `transient` → Skip. +// +// When multiple failing classes coexist (e.g., `failed` and +// `missing` together), the dominant class drives the headline and +// Suggestion. A short "other agents have additional issues" hint is +// appended to the Suggestion so the user knows to read Details for +// the second-priority fix path. +// +// Skip-cascade (per design dependency matrix lines 110-117 of +// azd-ai-agent-doctor-remote-checks.md): +// +// 1. `local.environment-selected` — env reads would Fail. +// 2. `local.agent-service-detected` — without a service list +// there's nothing to probe; cascade rather than emit a Pass +// ("0 of 0 agents active") which reads as a bug. +// 3. `remote.auth` — without a valid token every probe would 401. +// 4. `remote.foundry-endpoint` — if the endpoint isn't reachable +// none of the probes will land; surface a single Skip rather +// than `N×` identical transport errors. +// +// We deliberately do NOT cascade from `remote.rbac`. Agent-list / +// agent-get is a Reader-level operation; a developer with read +// access but no deploy role can still see whether their agents +// are healthy, and surfacing that information is the whole point +// of the check. +func newCheckAgentStatus(deps Dependencies) Check { + apiVersion := deps.AgentAPIVersion + return Check{ + ID: "remote.agent-status", + Name: "Hosted agents are active", + Remote: true, + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable.", + } + } + if priorBlocked(prior, "local.environment-selected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no azd environment is selected " + + "(see check `local.environment-selected`).", + } + } + if priorBlocked(prior, "local.agent-service-detected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no `azure.ai.agent` service in " + + "azure.yaml (see check " + + "`local.agent-service-detected`).", + } + } + if priorBlocked(prior, "remote.auth") { + return Result{ + Status: StatusSkip, + Message: "skipped: auth probe did not succeed " + + "(see check `remote.auth`).", + } + } + if priorBlocked(prior, "remote.foundry-endpoint") { + return Result{ + Status: StatusSkip, + Message: "skipped: Foundry endpoint did not respond " + + "(see check `remote.foundry-endpoint`).", + } + } + endpoint := readProjectEndpoint(prior) + if endpoint == "" { + return Result{ + Status: StatusSkip, + Message: "skipped: upstream check passed but did not " + + "surface AZURE_AI_PROJECT_ENDPOINT in its Details.", + } + } + if apiVersion == "" { + return Result{ + Status: StatusSkip, + Message: "skipped: doctor wiring did not provide an " + + "agent API version for the probe.", + } + } + services := readAgentServices(prior) + if len(services) == 0 { + return Result{ + Status: StatusSkip, + Message: "skipped: upstream check passed but did not " + + "surface agent service names in its Details.", + } + } + + nameVersionReader := deps.readAgentNameVersionFn + if nameVersionReader == nil { + nameVersionReader = readAgentNameVersion + } + probe := deps.probeAgentStatus + if probe == nil { + probe = makeRealProbeAgentStatus(apiVersion) + } + + entries := probeAllServices( + ctx, deps.AzdClient, services, endpoint, + nameVersionReader, probe) + + return classifyAgentStatusAggregate(entries) + }, + } +} + +// probeOneService is the per-service body of the check loop. +// Factored out so unit tests can drive a single service without +// reconstructing the surrounding closure; production callers should +// use the parent `newCheckAgentStatus` rather than calling this +// directly. +func probeOneService( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName, endpoint string, + readNameVersion func(context.Context, *azdext.AzdClient, string) (string, string, error), + probe func(context.Context, string, string, string) agentStatusProbeResult, +) agentStatusEntry { + entry := agentStatusEntry{Service: serviceName} + + name, ver, err := readNameVersion(ctx, azdClient, serviceName) + if err != nil { + entry.Classification = agentClassTransientErr + entry.Detail = fmt.Sprintf( + "could not read deployed agent name/version: %s", + firstLine(err.Error())) + return entry + } + if name == "" { + entry.Classification = agentClassNotDeployed + entry.Detail = fmt.Sprintf( + "service %q has not been deployed yet "+ + "(AGENT_%s_NAME is unset).", + serviceName, doctorServiceKey(serviceName)) + return entry + } + entry.AgentName = name + if ver == "" { + // We have a deployed name but no version. This is an + // inconsistent state — the post-deploy hook writes both + // vars atomically, so a present-NAME / absent-VERSION + // means deployment never completed (or env was edited by + // hand). Treat as not-deployed so the user is directed to + // re-run `azd deploy` rather than told to retry doctor. + entry.Classification = agentClassNotDeployed + entry.Detail = fmt.Sprintf( + "service %q has AGENT_%s_NAME set but "+ + "AGENT_%s_VERSION is missing; the previous "+ + "deployment did not complete cleanly.", + serviceName, + doctorServiceKey(serviceName), doctorServiceKey(serviceName)) + return entry + } + entry.AgentVersion = ver + + probeCtx, cancel := context.WithTimeout(ctx, agentStatusProbeTimeout) + defer cancel() + res := probe(probeCtx, endpoint, name, ver) + entry.HTTPStatus = res.statusCode + entry.Status = res.status + + switch { + case errors.Is(res.err, context.Canceled): + entry.Classification = agentClassTransientErr + entry.Detail = "probe was cancelled." + return entry + case errors.Is(res.err, context.DeadlineExceeded): + entry.Classification = agentClassTransientErr + entry.Detail = fmt.Sprintf( + "probe did not respond within %s.", + agentStatusProbeTimeout) + return entry + case res.statusCode == http.StatusNotFound: + entry.Classification = agentClassMissing + entry.Detail = fmt.Sprintf( + "agent %q (version %s) was not found on the Foundry project.", + name, ver) + return entry + case res.err != nil: + entry.Classification = agentClassTransientErr + entry.Detail = fmt.Sprintf( + "probe failed: %s", + firstLine(res.err.Error())) + return entry + } + + // Status branch. Match case-insensitively because Foundry has + // shipped both Pascal-cased and lower-cased lifecycle values + // historically; the production invoke flow normalizes with + // EqualFold. + switch { + case strings.EqualFold(res.status, agentStatusActive): + entry.Classification = agentClassActive + entry.Detail = fmt.Sprintf("agent active (v%s).", ver) + case strings.EqualFold(res.status, agentStatusCreating): + entry.Classification = agentClassDeploying + entry.Detail = fmt.Sprintf( + "agent is still deploying (v%s).", ver) + case strings.EqualFold(res.status, agentStatusFailed): + entry.Classification = agentClassFailed + entry.Detail = fmt.Sprintf( + "agent deployment failed (v%s).", ver) + case strings.EqualFold(res.status, agentStatusDeleting), + strings.EqualFold(res.status, agentStatusDeleted): + entry.Classification = agentClassMissing + entry.Detail = fmt.Sprintf( + "agent has been deleted or is being deleted (v%s).", ver) + default: + entry.Classification = agentClassUnknown + entry.Detail = fmt.Sprintf( + "agent in unrecognized status %q (v%s).", res.status, ver) + } + return entry +} + +// probeAllServices runs probeOneService across `services` with +// bounded concurrency (probeConcurrency workers). Order in the +// returned slice mirrors the input `services` order so that the +// aggregate's downstream sort + Details rendering remain +// deterministic regardless of which worker finishes first. +// +// The function does not introduce a separate timeout — each +// probeOneService call enforces its own per-probe timeout via +// agentStatusProbeTimeout, and the parent ctx propagates +// cancellation to all in-flight workers. +func probeAllServices( + ctx context.Context, + azdClient *azdext.AzdClient, + services []string, + endpoint string, + readNameVersion func(context.Context, *azdext.AzdClient, string) (string, string, error), + probe func(context.Context, string, string, string) agentStatusProbeResult, +) []agentStatusEntry { + entries := make([]agentStatusEntry, len(services)) + sem := make(chan struct{}, probeConcurrency) + var wg sync.WaitGroup + for i, svc := range services { + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() + entries[i] = probeOneService( + ctx, azdClient, svc, endpoint, + readNameVersion, probe) + }) + } + wg.Wait() + return entries +} + +// classifyAgentStatusAggregate folds the per-service entries into a +// single doctor Result. The aggregate Status is the worst +// per-service Classification's bucket; the Message lists each +// failing service one line at a time (truncated to 3 with a +// trailing "(N more)" if needed); the Suggestion targets the +// dominant fix path. +// +// We list services even when they're healthy in the Pass message, +// so a user running the check sees what was probed. +// +// Heterogeneous failing classes (e.g., one Failed agent and one +// Missing agent in the same project) collapse to the +// highest-ranked class for headline/count purposes, but the +// detail lines are filtered to that dominant class only so the +// headline count and the rendered body always agree. A short hint +// is appended to the Suggestion telling the user to read Details +// for the secondary class. +func classifyAgentStatusAggregate(entries []agentStatusEntry) Result { + // Sort for deterministic Message / Details rendering. + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].Service < entries[j].Service + }) + + // Find the worst classification present. + worst := agentClassActive + for _, e := range entries { + if rank(e.Classification) > rank(worst) { + worst = e.Classification + } + } + + // Tally per-class for the summary and for the "all Active / + // some skipped" mix-case branch. + byClass := map[string]int{} + for _, e := range entries { + byClass[e.Classification]++ + } + + details := map[string]any{ + "services": entries, + "byClassification": byClass, + } + + total := len(entries) + + // detailsForClass returns "{service}: {detail}" lines for + // exactly the given class. The headline count uses + // byClass[worst]; using class-filtered detail lines here keeps + // the rendered body in sync with that count even when entries + // from multiple failing classes are present. + detailsForClass := func(class string) []string { + out := make([]string, 0, byClass[class]) + for _, e := range entries { + if e.Classification == class { + out = append(out, fmt.Sprintf("%s: %s", e.Service, e.Detail)) + } + } + return out + } + + // otherFailingClasses returns the non-{worst, active, transient} + // classes that are also present, sorted for determinism. Used + // to enrich the dominant Suggestion when the run contains a + // mix of failing classes. + otherFailingClasses := func() []string { + out := []string{} + for class, n := range byClass { + if n == 0 || + class == worst || + class == agentClassActive || + class == agentClassTransientErr { + continue + } + out = append(out, class) + } + sort.Strings(out) + return out + } + + // appendOthersHint enriches a Suggestion when entries from + // other failing classes coexist with the dominant class. + appendOthersHint := func(s string) string { + others := otherFailingClasses() + if len(others) == 0 { + return s + } + return s + " Other agents have additional issues " + + "(" + strings.Join(others, ", ") + "); " + + "see the per-service Details for the full list." + } + + switch worst { + case agentClassActive: + // All healthy. + names := serviceNamesByClass(entries, agentClassActive) + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "%d of %d agents active: %s.", + byClass[agentClassActive], total, + strings.Join(names, ", ")), + Details: details, + } + case agentClassTransientErr: + // Per the documented aggregate rule, a transient probe + // failure for one service should not mask the healthy + // status of the rest — when at least one Active is + // present we surface a Pass with a Message note that + // some probes were skipped. + if byClass[agentClassActive] > 0 { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "%d of %d agents active; %d probe(s) skipped: %s", + byClass[agentClassActive], total, + byClass[agentClassTransientErr], + firstTransient(entries)), + Details: details, + } + } + // All probes skipped — surface as Skip with the highest- + // signal detail. + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: %d agent probe(s) did not complete: %s", + byClass[agentClassTransientErr], firstTransient(entries)), + Suggestion: "Retry `azd ai agent doctor` after a moment; if " + + "the failure persists, verify Foundry reachability and " + + "that the agents have been deployed.", + Details: details, + } + case agentClassDeploying: + return Result{ + Status: StatusWarn, + Message: fmt.Sprintf( + "%d of %d agents are still deploying: %s.", + byClass[agentClassDeploying], total, + strings.Join(serviceNamesByClass(entries, agentClassDeploying), ", ")), + Suggestion: appendOthersHint("Watch progress with " + + "`azd ai agent monitor --follow`; the agent is not yet " + + "available for invocation."), + Details: details, + } + case agentClassNotDeployed: + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d of %d agents have not been deployed:\n %s", + byClass[agentClassNotDeployed], total, + strings.Join(truncateLines(detailsForClass(agentClassNotDeployed), 3), "\n ")), + Suggestion: appendOthersHint("Run `azd deploy` to deploy the missing agents."), + Details: details, + } + case agentClassMissing: + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d of %d agents are missing on the Foundry project:\n %s", + byClass[agentClassMissing], total, + strings.Join(truncateLines(detailsForClass(agentClassMissing), 3), "\n ")), + Suggestion: appendOthersHint("Run `azd deploy` to re-create the missing " + + "agent(s) on the Foundry project."), + Details: details, + } + case agentClassFailed: + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d of %d agents are in a failed state:\n %s", + byClass[agentClassFailed], total, + strings.Join(truncateLines(detailsForClass(agentClassFailed), 3), "\n ")), + Suggestion: appendOthersHint("Inspect the failure with " + + "`azd ai agent monitor --follow` to read the deploy " + + "logs; redeploy only after addressing the root cause."), + Details: details, + } + case agentClassUnknown: + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d of %d agents reported an unrecognized status:\n %s", + byClass[agentClassUnknown], total, + strings.Join(truncateLines(detailsForClass(agentClassUnknown), 3), "\n ")), + Suggestion: appendOthersHint("Inspect the agent in the Foundry portal; if " + + "the status looks healthy there, this is likely a " + + "transient Foundry / extension mismatch — retry " + + "`azd ai agent doctor` after a moment."), + Details: details, + } + default: + // Defensive: agentClassRank covers every constant in this + // file; reaching this branch would mean a constant was + // added without updating the rank map. Surface as a + // transient Skip so the user gets a sane next step. + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: doctor encountered an unhandled "+ + "classification %q.", worst), + } + } +} + +// rank returns the configured rank of a per-service classification, +// or 0 (active) when unknown. We never want a missing-from-map +// classification to drive the aggregate Status, so the safe default +// is the floor. +func rank(class string) int { + if r, ok := agentClassRank[class]; ok { + return r + } + return 0 +} + +// serviceNamesByClass returns the service names whose +// Classification matches the given class, preserving the input +// order (which is already sorted by the aggregate caller). +func serviceNamesByClass(entries []agentStatusEntry, class string) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + if e.Classification == class { + out = append(out, e.Service) + } + } + return out +} + +// firstTransient returns the first transient-class entry's Detail +// to use as the headline message when every service skipped. +// Returns "no diagnostic detail" if no transient entry has a non- +// empty Detail (defensive — production code always populates Detail). +func firstTransient(entries []agentStatusEntry) string { + for _, e := range entries { + if e.Classification == agentClassTransientErr && e.Detail != "" { + return e.Detail + } + } + return "no diagnostic detail" +} + +// truncateLines collapses a long slice into at most max entries, +// appending an "(N more)" sentinel if the input was longer. Used +// to keep the aggregate Message bounded when a project has many +// failing agents. +func truncateLines(lines []string, max int) []string { + if len(lines) <= max { + return lines + } + out := make([]string, 0, max+1) + out = append(out, lines[:max]...) + out = append(out, fmt.Sprintf("(%d more)", len(lines)-max)) + return out +} + +// readAgentServices pulls the agent service name list out of the +// upstream `local.agent-service-detected` check's Details. Returns +// nil when the upstream check did not surface the field (e.g., +// because it Skipped or because its shape was refactored). The +// caller is responsible for deciding whether nil = Skip; we don't +// guess here because there's no safe default. +func readAgentServices(prior []Result) []string { + for _, p := range prior { + if p.ID != "local.agent-service-detected" { + continue + } + v, ok := p.Details["agentServices"].([]string) + if !ok { + return nil + } + return v + } + return nil +} + +// readAgentNameVersion pulls AGENT__NAME and AGENT__VERSION +// out of the active azd environment for the given service. Returns +// the trimmed values verbatim — an empty string from either field +// means the variable was unset, which the caller distinguishes from +// a transport error. +// +// EnvName is intentionally left empty: the gRPC service resolves +// "" to the currently-active env (see +// `internal/grpcserver/environment_service.go:GetValue`), which is +// the same env any subsequent `azd deploy` would write to. +func readAgentNameVersion( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, +) (string, string, error) { + key := doctorServiceKey(serviceName) + nameKey := fmt.Sprintf("AGENT_%s_NAME", key) + verKey := fmt.Sprintf("AGENT_%s_VERSION", key) + + nameResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + Key: nameKey, + }) + if err != nil { + return "", "", fmt.Errorf("read %s: %w", nameKey, err) + } + verResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + Key: verKey, + }) + if err != nil { + return "", "", fmt.Errorf("read %s: %w", verKey, err) + } + name := "" + if nameResp != nil { + name = strings.TrimSpace(nameResp.Value) + } + ver := "" + if verResp != nil { + ver = strings.TrimSpace(verResp.Value) + } + return name, ver, nil +} + +// doctorServiceKey converts a service name into the env var key +// format (uppercase, underscores). Mirrors `cmd.toServiceKey` — +// duplicated here because the doctor package cannot import the +// parent `cmd` package without forming an import cycle (the same +// rationale as for `agentHost` in checks_project.go). Must stay +// in sync with `cmd/helpers.go:680`. +func doctorServiceKey(serviceName string) string { + key := strings.ReplaceAll(serviceName, " ", "_") + key = strings.ReplaceAll(key, "-", "_") + return strings.ToUpper(key) +} + +// makeRealProbeAgentStatus returns the production probe closure for +// the given api-version. It builds a credential via the same +// `NewAzureDeveloperCLICredential` path used by `agent_context.go` +// (so a Pass here matches what the runtime invoke flow needs) and +// invokes `agent_api.GetAgentVersion`. +// +// The closure handles HTTP-status / transport / response-body +// classification by sniffing the SDK error: `azcore.ResponseError` +// exposes `StatusCode`, which we surface in `statusCode` so the +// caller can route 404s onto the missing-class branch without +// re-parsing error strings. +func makeRealProbeAgentStatus( + apiVersion string, +) func(context.Context, string, string, string) agentStatusProbeResult { + return func( + ctx context.Context, + endpoint, agentName, agentVersion string, + ) agentStatusProbeResult { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return agentStatusProbeResult{ + err: fmt.Errorf("create credential: %w", err), + } + } + + client := agent_api.NewAgentClient(endpoint, cred) + v, err := client.GetAgentVersion( + ctx, agentName, agentVersion, apiVersion) + if err != nil { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + return agentStatusProbeResult{ + statusCode: respErr.StatusCode, + err: err, + } + } + return agentStatusProbeResult{err: err} + } + if v == nil { + return agentStatusProbeResult{ + err: errors.New("GetAgentVersion returned nil"), + } + } + return agentStatusProbeResult{ + statusCode: http.StatusOK, + status: v.Status, + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_status_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_status_test.go new file mode 100644 index 00000000000..fbc9b4b8539 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_agent_status_test.go @@ -0,0 +1,818 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// healthyPriorResults returns the canonical "all upstream checks +// passed" prior slice used by the agent-status check tests. The +// service list `services` is surfaced under +// `local.agent-service-detected` Details exactly as the production +// check surfaces it (see checks_project.go:91); the endpoint is +// surfaced under `local.project-endpoint-set` Details to match the +// production wiring in checks_local.go's project endpoint check. +func healthyPriorResults(services []string, endpoint string) []Result { + return []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass, Details: map[string]any{ + "agentServices": services, + }}, + {ID: "local.project-endpoint-set", Status: StatusPass, Details: map[string]any{ + "projectEndpoint": endpoint, + }}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusPass}, + } +} + +// fixedNameVersionReader returns a stub readAgentNameVersionFn that +// looks up names/versions from a static map keyed by service name. +// Missing key → empty strings (matches the production "AGENT__NAME +// unset" path). Used by every test that needs to drive the per-service +// loop without spinning up a real gRPC env service. +type pair struct{ name, version string } + +func fixedNameVersionReader( + m map[string]pair, +) func(context.Context, *azdext.AzdClient, string) (string, string, error) { + return func(_ context.Context, _ *azdext.AzdClient, svc string) (string, string, error) { + v, ok := m[svc] + if !ok { + return "", "", nil + } + return v.name, v.version, nil + } +} + +// fixedProbe returns a stub agent-status probe that looks up the +// probe result by a (name, version) key — distinct services with +// distinct (name, version) pairs can simulate heterogeneous +// classifications inside a single aggregate run. +type probeKey struct{ name, version string } + +func fixedProbe( + m map[probeKey]agentStatusProbeResult, +) func(context.Context, string, string, string) agentStatusProbeResult { + return func(_ context.Context, _ string, name, version string) agentStatusProbeResult { + v, ok := m[probeKey{name, version}] + if !ok { + return agentStatusProbeResult{ + err: errors.New("probe stub: unexpected (name, version)"), + } + } + return v + } +} + +// runCheckWithDeps invokes the check Fn with the given prior / +// options / dependencies. Returns the produced Result. +func runCheckWithDeps(t *testing.T, deps Dependencies, prior []Result) Result { + t.Helper() + // AzdClient must be non-nil to clear the first skip guard; tests + // don't actually call into it because the readAgentNameVersionFn + // seam diverts every env read to the stub. + if deps.AzdClient == nil { + deps.AzdClient = &azdext.AzdClient{} + } + if deps.AgentAPIVersion == "" { + deps.AgentAPIVersion = "2025-11-15-preview" + } + c := newCheckAgentStatus(deps) + require.NotNil(t, c.Fn, "newCheckAgentStatus must return a non-nil Fn") + return c.Fn(t.Context(), Options{}, prior) +} + +// ---- Skip-cascade gates ---- + +func TestCheckAgentStatus_SkipsWhenAzdClientNil(t *testing.T) { + t.Parallel() + c := newCheckAgentStatus(Dependencies{}) + res := c.Fn(t.Context(), Options{}, nil) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "azd extension not reachable") +} + +func TestCheckAgentStatus_SkipsWhenEnvironmentNotSelected(t *testing.T) { + t.Parallel() + deps := Dependencies{AzdClient: &azdext.AzdClient{}} + prior := []Result{{ID: "local.environment-selected", Status: StatusFail}} + res := newCheckAgentStatus(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "local.environment-selected") +} + +func TestCheckAgentStatus_SkipsWhenAgentServiceDetectedFailed(t *testing.T) { + t.Parallel() + deps := Dependencies{AzdClient: &azdext.AzdClient{}} + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusFail}, + } + res := newCheckAgentStatus(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "local.agent-service-detected") +} + +func TestCheckAgentStatus_SkipsWhenAuthFailed(t *testing.T) { + t.Parallel() + deps := Dependencies{AzdClient: &azdext.AzdClient{}} + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass, Details: map[string]any{ + "agentServices": []string{"echo"}, + }}, + {ID: "remote.auth", Status: StatusFail}, + } + res := newCheckAgentStatus(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "remote.auth") +} + +func TestCheckAgentStatus_SkipsWhenFoundryEndpointFailed(t *testing.T) { + t.Parallel() + deps := Dependencies{AzdClient: &azdext.AzdClient{}} + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass, Details: map[string]any{ + "agentServices": []string{"echo"}, + }}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusFail}, + } + res := newCheckAgentStatus(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "remote.foundry-endpoint") +} + +func TestCheckAgentStatus_DoesNotSkipOnRBACFail(t *testing.T) { + t.Parallel() + // RBAC failure must NOT prevent agent-status from running: agent-list + // is a Reader-level call and a developer with read-only access on + // the Foundry project still benefits from knowing whether their + // agents are healthy. Pin this contract so a future refactor does + // not accidentally couple the two checks. + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusActive, + }, + }), + } + prior := append( + healthyPriorResults([]string{"echo"}, "https://example.foundry"), + Result{ID: "remote.rbac", Status: StatusFail}, + ) + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusPass, res.Status, + "agent-status must run even when remote.rbac failed") +} + +func TestCheckAgentStatus_SkipsWhenEndpointMissingFromUpstream(t *testing.T) { + t.Parallel() + deps := Dependencies{AzdClient: &azdext.AzdClient{}} + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass, Details: map[string]any{ + "agentServices": []string{"echo"}, + }}, + // project-endpoint-set passed but didn't surface the value: + {ID: "local.project-endpoint-set", Status: StatusPass}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusPass}, + } + res := newCheckAgentStatus(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "AZURE_AI_PROJECT_ENDPOINT") +} + +func TestCheckAgentStatus_SkipsWhenAgentServiceListMissingFromUpstream(t *testing.T) { + t.Parallel() + deps := Dependencies{AzdClient: &azdext.AzdClient{}, AgentAPIVersion: "2025-11-15-preview"} + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + // agent-service-detected passed but didn't surface the list: + {ID: "local.agent-service-detected", Status: StatusPass}, + {ID: "local.project-endpoint-set", Status: StatusPass, Details: map[string]any{ + "projectEndpoint": "https://example.foundry", + }}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusPass}, + } + res := newCheckAgentStatus(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "agent service names") +} + +func TestCheckAgentStatus_SkipsWhenAPIVersionEmpty(t *testing.T) { + t.Parallel() + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + AgentAPIVersion: "", // production wiring should always populate this + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := newCheckAgentStatus(deps).Fn(t.Context(), Options{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "agent API version") +} + +// ---- Per-service classification ---- + +func TestCheckAgentStatus_AllActive_Pass(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + "summ": {name: "summ-agent", version: "2"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusActive, + }, + {name: "summ-agent", version: "2"}: { + statusCode: http.StatusOK, status: agentStatusActive, + }, + }), + } + prior := healthyPriorResults([]string{"echo", "summ"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusPass, res.Status) + require.Contains(t, res.Message, "2 of 2 agents active") + require.Contains(t, res.Message, "echo") + require.Contains(t, res.Message, "summ") +} + +func TestCheckAgentStatus_CreatingOnly_Warn(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusCreating, + }, + }), + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusWarn, res.Status) + require.Contains(t, res.Suggestion, "monitor --follow") +} + +func TestCheckAgentStatus_Failed_FailPointsAtMonitor(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusFailed, + }, + }), + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Suggestion, "monitor --follow", + "Failed agents must point at monitor --follow, NOT azd deploy") + require.NotContains(t, res.Suggestion, "azd deploy", + "Failed agents must NOT suggest redeploying without diagnosis") +} + +func TestCheckAgentStatus_NotFound404_FailPointsAtDeploy(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusNotFound, + }, + }), + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Suggestion, "azd deploy") + require.Contains(t, res.Message, "missing") +} + +func TestCheckAgentStatus_NotDeployed_NoEnvVar_FailPointsAtDeploy(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + // "echo" intentionally absent → AGENT_ECHO_NAME unset. + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{}), + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Suggestion, "azd deploy") + require.Contains(t, res.Message, "not been deployed") +} + +func TestCheckAgentStatus_MismatchedNameVersion_NotDeployedForService(t *testing.T) { + t.Parallel() + // AGENT_ECHO_NAME is set but AGENT_ECHO_VERSION is missing. + // The post-deploy hook writes both vars atomically, so this is + // a deterministic "deployment never completed" state — classify + // as not-deployed and direct the user to `azd deploy`, not + // "retry doctor". + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: ""}, + "summ": {name: "summ-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "summ-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusActive, + }, + }), + } + prior := healthyPriorResults([]string{"echo", "summ"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + // not-deployed (rank 3) > active (rank 0), so the aggregate is + // a Fail; the Suggestion must point at `azd deploy`. + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Suggestion, "azd deploy") + require.NotContains(t, res.Suggestion, "Retry") + // Per-service Details classify the missing-version entry as + // not-deployed and the healthy entry as active. + services, ok := res.Details["services"].([]agentStatusEntry) + require.True(t, ok) + require.Len(t, services, 2) + // Sorted lexicographically (echo < summ). + require.Equal(t, "echo", services[0].Service) + require.Equal(t, agentClassNotDeployed, services[0].Classification) + require.Contains(t, services[0].Detail, "AGENT_ECHO_VERSION") + require.Equal(t, "summ", services[1].Service) + require.Equal(t, agentClassActive, services[1].Classification) +} + +func TestCheckAgentStatus_UnknownStatus_Fail(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: "Mysterious", + }, + }), + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "unrecognized") + require.Contains(t, res.Suggestion, "Foundry portal") +} + +func TestCheckAgentStatus_StatusCaseInsensitive(t *testing.T) { + t.Parallel() + // Foundry has historically shipped both Pascal-cased and + // lower-cased lifecycle values; the production invoke flow + // normalizes with EqualFold, so we must too. + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: "active", // lowercase + }, + }), + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusPass, res.Status) +} + +// ---- Aggregate behavior ---- + +func TestCheckAgentStatus_Aggregate_FailedDominatesActive(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + "summ": {name: "summ-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusActive, + }, + {name: "summ-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusFailed, + }, + }), + } + prior := healthyPriorResults([]string{"echo", "summ"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Suggestion, "monitor --follow") + require.Contains(t, res.Message, "summ") +} + +func TestCheckAgentStatus_Aggregate_MissingDominatesCreating(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + "summ": {name: "summ-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusCreating, + }, + {name: "summ-agent", version: "1"}: { + statusCode: http.StatusNotFound, + }, + }), + } + prior := healthyPriorResults([]string{"echo", "summ"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Suggestion, "azd deploy") +} + +func TestCheckAgentStatus_Aggregate_FailedDominatesMissing(t *testing.T) { + t.Parallel() + // failed rank (6) > missing rank (5), so the Suggestion points + // at monitor --follow (the Failed branch) rather than azd + // deploy (the Missing branch). Diagnose-before-redeploy is the + // correct order: a Failed agent has logs the user needs to read. + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + "summ": {name: "summ-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusNotFound, + }, + {name: "summ-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusFailed, + }, + }), + } + prior := healthyPriorResults([]string{"echo", "summ"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Suggestion, "monitor --follow") + // Headline count matches the Failed-class count (1), not the + // total non-active count (2). Detail body lists only the failed + // entry, not the missing one — count and body must agree. + require.Contains(t, res.Message, "1 of 2 agents are in a failed state") + require.Contains(t, res.Message, "summ:") + require.NotContains(t, res.Message, "echo:") + // Mixed-class Suggestion mentions the other failing classes so + // the user knows there's a second fix path in Details. + require.Contains(t, res.Suggestion, "Other agents have additional issues") + require.Contains(t, res.Suggestion, "missing") +} + +func TestCheckAgentStatus_Aggregate_ActiveAndTransient_PassWithNote(t *testing.T) { + t.Parallel() + // Documented aggregate rule: when the worst class is `transient` + // and at least one service is Active, the aggregate is Pass + // with a Message note that some probes were skipped. A + // transient probe failure for one service should not mask the + // healthy state of the others. + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + "summ": {name: "summ-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusActive, + }, + {name: "summ-agent", version: "1"}: { + err: errors.New("network unreachable"), + }, + }), + } + prior := healthyPriorResults([]string{"echo", "summ"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusPass, res.Status) + require.Contains(t, res.Message, "1 of 2 agents active") + require.Contains(t, res.Message, "probe(s) skipped") + require.Contains(t, res.Message, "network unreachable") + // No Suggestion needed for a Pass. + require.Empty(t, res.Suggestion) +} + +func TestCheckAgentStatus_Aggregate_AllTransient_Skip(t *testing.T) { + t.Parallel() + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + probeAgentStatus: fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + err: errors.New("network unreachable"), + }, + }), + } + prior := healthyPriorResults([]string{"echo"}, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "network unreachable") + require.Contains(t, res.Suggestion, "Retry") +} + +func TestCheckAgentStatus_Aggregate_TruncatesAtThreeFailingLines(t *testing.T) { + t.Parallel() + // Five failing services should produce a Message that lists + // three lines + "(2 more)". Confirms the truncateLines helper + // is wired into the aggregate Message. + names := []string{"a", "b", "c", "d", "e"} + versions := map[string]pair{} + probes := map[probeKey]agentStatusProbeResult{} + for _, n := range names { + versions[n] = pair{name: n + "-agent", version: "1"} + probes[probeKey{n + "-agent", "1"}] = agentStatusProbeResult{ + statusCode: http.StatusNotFound, + } + } + deps := Dependencies{ + readAgentNameVersionFn: fixedNameVersionReader(versions), + probeAgentStatus: fixedProbe(probes), + } + prior := healthyPriorResults(names, "https://example.foundry") + res := runCheckWithDeps(t, deps, prior) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "(2 more)") + // Three full detail lines visible. + require.Equal(t, 3, strings.Count(res.Message, "version 1")) +} + +// ---- probeOneService transport branches ---- + +func TestProbeOneService_404_Missing(t *testing.T) { + t.Parallel() + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusNotFound, + }, + }), + ) + require.Equal(t, agentClassMissing, entry.Classification) + require.Equal(t, http.StatusNotFound, entry.HTTPStatus) +} + +func TestProbeOneService_TransportError_Transient(t *testing.T) { + t.Parallel() + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + err: errors.New("dns lookup failed"), + }, + }), + ) + require.Equal(t, agentClassTransientErr, entry.Classification) + require.Contains(t, entry.Detail, "dns lookup failed") +} + +func TestProbeOneService_ContextCancelled_Transient(t *testing.T) { + t.Parallel() + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + err: context.Canceled, + }, + }), + ) + require.Equal(t, agentClassTransientErr, entry.Classification) + require.Contains(t, entry.Detail, "cancelled") +} + +func TestProbeOneService_DeadlineExceeded_Transient(t *testing.T) { + t.Parallel() + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + err: context.DeadlineExceeded, + }, + }), + ) + require.Equal(t, agentClassTransientErr, entry.Classification) + require.Contains(t, entry.Detail, "did not respond") +} + +func TestProbeOneService_DeletedStatus_Missing(t *testing.T) { + t.Parallel() + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusDeleted, + }, + }), + ) + require.Equal(t, agentClassMissing, entry.Classification) +} + +func TestProbeOneService_DeletingStatus_Missing(t *testing.T) { + t.Parallel() + // Vienna's AgentVersionStatus surfaces both `Deleted` and + // `Deleting`; both should classify as `missing` so the user is + // directed to redeploy rather than wait for an agent that's + // being torn down. + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + fixedProbe(map[probeKey]agentStatusProbeResult{ + {name: "echo-agent", version: "1"}: { + statusCode: http.StatusOK, status: agentStatusDeleting, + }, + }), + ) + require.Equal(t, agentClassMissing, entry.Classification) + require.Contains(t, entry.Detail, "deleted or is being deleted") +} + +func TestProbeOneService_NameSetVersionEmpty_NotDeployed(t *testing.T) { + t.Parallel() + // The post-deploy hook writes AGENT__NAME and + // AGENT__VERSION atomically; a present-NAME / absent-VERSION + // is therefore a "deployment never completed" state — classify + // as not-deployed so the user is told to `azd deploy`, not to + // retry the doctor. + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: ""}, + }), + fixedProbe(nil), + ) + require.Equal(t, agentClassNotDeployed, entry.Classification) + require.Contains(t, entry.Detail, "AGENT_ECHO_VERSION") + require.Contains(t, entry.Detail, "did not complete") +} + +func TestProbeOneService_ReadNameVersionError_Transient(t *testing.T) { + t.Parallel() + reader := func(_ context.Context, _ *azdext.AzdClient, _ string) (string, string, error) { + return "", "", errors.New("gRPC channel closed") + } + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + reader, fixedProbe(nil), + ) + require.Equal(t, agentClassTransientErr, entry.Classification) + require.Contains(t, entry.Detail, "gRPC channel closed") +} + +// ---- Service-key edge cases ---- + +func TestDoctorServiceKey_HandlesHyphensSpacesAndCase(t *testing.T) { + t.Parallel() + cases := []struct { + input string + want string + }{ + {"echo", "ECHO"}, + {"my-agent", "MY_AGENT"}, + {"my agent", "MY_AGENT"}, + {"My-Agent Name", "MY_AGENT_NAME"}, + } + for _, c := range cases { + require.Equal(t, c.want, doctorServiceKey(c.input), + "input=%q", c.input) + } +} + +// ---- Helper functions ---- + +func TestRank_FallsBackToActiveForUnknownClass(t *testing.T) { + t.Parallel() + // Defensive: an unknown classification must NOT outrank a real + // class (otherwise it would silently drive the aggregate Status). + require.Equal(t, 0, rank("not-a-real-class")) + require.Equal(t, 0, rank(agentClassActive)) + require.Greater(t, rank(agentClassFailed), rank(agentClassActive)) +} + +func TestTruncateLines_AtAndBelowMax(t *testing.T) { + t.Parallel() + require.Equal(t, []string{"a", "b"}, truncateLines([]string{"a", "b"}, 3)) + require.Equal(t, []string{"a", "b", "c"}, truncateLines([]string{"a", "b", "c"}, 3)) + require.Equal(t, + []string{"a", "b", "c", "(2 more)"}, + truncateLines([]string{"a", "b", "c", "d", "e"}, 3)) +} + +func TestReadAgentServices_MissingDetailsReturnsNil(t *testing.T) { + t.Parallel() + prior := []Result{ + {ID: "local.agent-service-detected", Status: StatusPass}, + } + require.Nil(t, readAgentServices(prior)) +} + +func TestReadAgentServices_WrongTypeReturnsNil(t *testing.T) { + t.Parallel() + prior := []Result{ + {ID: "local.agent-service-detected", Status: StatusPass, Details: map[string]any{ + "agentServices": "echo,summ", // wrong type — should be []string + }}, + } + require.Nil(t, readAgentServices(prior)) +} + +// ---- Real-probe shape ---- + +func TestMakeRealProbeAgentStatus_ReturnsNonNilCloser(t *testing.T) { + t.Parallel() + // We can't easily test the real probe without an Azure subscription, + // but we can pin the factory: it must return a non-nil closure that + // surfaces a credential-creation error or a network error rather + // than panicking when called. + probe := makeRealProbeAgentStatus("2025-11-15-preview") + require.NotNil(t, probe) + // Invoking with an obviously-invalid endpoint should still + // produce a structured result (not a panic). We pass a very + // short context to avoid waiting on real network. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + res := probe(ctx, "https://localhost:1/", "no-agent", "1") + // We don't assert on the specific error because behavior varies + // by environment; the contract is "returns without panic". + _ = res +} + +// ---- azcore.ResponseError unwrap path ---- + +func TestProbeAgentStatus_AzcoreResponseErrorSurfacesStatusCode(t *testing.T) { + t.Parallel() + // The production closure uses errors.AsType to unwrap an + // azcore.ResponseError into a statusCode for the missing-class + // branch. Pin that contract here using a synthetic ResponseError + // to make sure a future SDK refactor that wraps the error + // differently surfaces in this test rather than at runtime. + respErr := &azcore.ResponseError{ + StatusCode: http.StatusNotFound, + ErrorCode: "AgentNotFound", + } + require.Equal(t, http.StatusNotFound, respErr.StatusCode, + "sanity check on synthetic ResponseError shape") + + // Build a stub probe that mimics what the real closure produces + // when the SDK returns a wrapped 404, then run the per-service + // entry path to confirm it routes to agentClassMissing. + entry := probeOneService( + t.Context(), &azdext.AzdClient{}, "echo", "https://example.foundry", + fixedNameVersionReader(map[string]pair{ + "echo": {name: "echo-agent", version: "1"}, + }), + func(_ context.Context, _ string, _ string, _ string) agentStatusProbeResult { + return agentStatusProbeResult{ + statusCode: respErr.StatusCode, + err: respErr, + } + }, + ) + require.Equal(t, agentClassMissing, entry.Classification, + "a wrapped ResponseError with 404 must route to missing") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_auth.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_auth.go new file mode 100644 index 00000000000..3a82c473e7e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_auth.go @@ -0,0 +1,316 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// authProbeTimeout caps the per-probe network call. The design's +// Performance Budget section (`.tmp/pr-8057/azd-ai-agent-doctor- +// remote-checks.md`) allows 2s for cached-token reads and one token- +// refresh round trip; 10s gives generous headroom for slow shells +// without making the user wait through a stuck `azd auth token` +// invocation. +const authProbeTimeout = 10 * time.Second + +// authLoginLink is the MS Learn URL for the `azd auth login` command +// reference, reused across every Fail/Warn branch whose suggestion +// is "run `azd auth login`". Keeping it as a package constant ensures +// every branch points to the same canonical doc and prevents drift +// between the four `Result` returns below. +const authLoginLink = "https://learn.microsoft.com/azure/developer/" + + "azure-developer-cli/reference#azd-auth-login" + +// authWarnThreshold is the validity floor below which the check warns +// the user to re-login proactively. Set to 5 minutes so a long-running +// deploy or invoke started immediately after the check has a fresh +// token instead of 401'ing mid-flight. +const authWarnThreshold = 5 * time.Minute + +// authScope is the Azure resource scope requested for the probe token. +// It matches the production scope used by `agent_api/operations.go` +// (`https://ai.azure.com/.default`), so a Pass here exactly mirrors +// what the agent invoke flow needs at runtime — not a different scope +// that might succeed when the runtime scope would fail. +const authScope = "https://ai.azure.com/.default" + +// authProbeResult is the structured outcome of one auth probe. The +// shape is dictated by the testability split: the production probe +// (realProbeAuth) talks to azidentity and returns the same fields a +// test seam fills synthetically. +// +// upn is the User Principal Name extracted from the access token's +// `upn` / `unique_name` / `preferred_username` / `email` claim +// (whichever is present first). Empty when none of those claims are +// readable — token is still valid, the check just renders without +// the identifier. +// +// validFor is the duration from probe time to token expiry. Zero or +// negative means the token is expired (defensive — GetToken normally +// refreshes before returning, but we surface this honestly if it +// happens). +// +// err captures token acquisition failure. When non-nil the other +// fields are zero-valued; callers must branch on err first. +type authProbeResult struct { + upn string + validFor time.Duration + err error +} + +// newCheckAuth produces Check `remote.auth`. It runs after the local +// chain because its skip-cascade reads `local.environment-selected`'s +// result from `prior` — without an active env there is no project to +// reason about and the check Skips with "select an env first" rather +// than running unconditionally. +// +// The check itself is intentionally narrow: it answers "does +// `azd auth token` succeed and how long until the token expires?" and +// nothing else. Wrong-tenant detection is the job of check +// `remote.foundry-endpoint` (C12) where a 403 maps to the precise +// "wrong tenant or insufficient RBAC" suggestion. Conflating the two +// here would produce false positives — flagging auth as broken when +// the user IS authenticated, just against the wrong tenant. +// +// Skip-cascade: only on `local.environment-selected`. Per the design +// dependency matrix, an env must be selected before remote checks +// have a project to test against. Other local checks (e.g., +// `local.grpc-extension`) do not gate auth — the probe uses +// `azidentity.NewAzureDeveloperCLICredential` directly and does not +// require an AzdClient. +func newCheckAuth(deps Dependencies) Check { + return Check{ + ID: "remote.auth", + Name: "authentication", + Remote: true, + Fn: func(ctx context.Context, opts Options, prior []Result) Result { + if priorBlocked(prior, "local.environment-selected") { + return Result{ + Status: StatusSkip, + Message: "skipped: select an azd environment first " + + "(see check `local.environment-selected`).", + } + } + + probe := deps.probeAuth + if probe == nil { + probe = realProbeAuth + } + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + defer cancel() + res := probe(probeCtx) + + displayUPN := redactUPN(res.upn, opts.Unredacted) + + if res.err != nil { + // Classify cancellation / timeout separately so we + // don't tell the user to run `azd auth login` when + // the real cause is a cancelled doctor command or a + // probe timeout. `errors.Is` correctly walks the + // wrap chain that azidentity returns. We check the + // outer ctx first so user-initiated cancellation + // (Ctrl-C) shadows the timeout that would also fire. + if errors.Is(ctx.Err(), context.Canceled) || + errors.Is(res.err, context.Canceled) { + return Result{ + Status: StatusSkip, + Message: "skipped: auth probe was cancelled before completion.", + } + } + if errors.Is(probeCtx.Err(), context.DeadlineExceeded) || + errors.Is(res.err, context.DeadlineExceeded) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "token acquisition timed out after %s.", + authProbeTimeout), + Suggestion: "Retry `azd ai agent doctor`; if the timeout " + + "persists, check your network or run " + + "`azd auth login` to refresh the credential cache.", + } + } + return Result{ + Status: StatusFail, + Message: "token acquisition failed: " + firstLine(res.err.Error()), + Suggestion: "Run `azd auth login` to authenticate.", + Links: []string{authLoginLink}, + } + } + + if res.validFor <= 0 { + return Result{ + Status: StatusFail, + Message: composeAuthMessage(displayUPN, "token has expired"), + Suggestion: "Run `azd auth login` to refresh the token.", + Links: []string{authLoginLink}, + } + } + minutes := int(res.validFor.Minutes()) + if res.validFor < authWarnThreshold { + return Result{ + Status: StatusWarn, + Message: composeAuthMessage(displayUPN, + "token expires in "+formatTokenWindow(res.validFor)), + Suggestion: "Run `azd auth login` to refresh the token " + + "before it expires.", + Links: []string{authLoginLink}, + Details: authDetails(res.upn, minutes, opts.Unredacted), + } + } + return Result{ + Status: StatusPass, + Message: composeAuthMessage(displayUPN, + "token valid for "+formatTokenWindow(res.validFor)), + Details: authDetails(res.upn, minutes, opts.Unredacted), + } + }, + } +} + +// realProbeAuth is the production implementation of the auth probe. +// It constructs the same AzureDeveloperCLICredential the extension +// uses at runtime (`internal/cmd/agent_context.go:103` — +// `newAgentCredential`), requests a token for the production scope, +// and decodes the access token's JWT payload for a UPN claim. +// +// We intentionally use an empty AzureDeveloperCLICredentialOptions +// (no TenantID override) so the probe targets the user's home tenant. +// Wrong-tenant scenarios are detected by check `remote.foundry-endpoint` +// (C12) where a 403 against the project endpoint maps to a precise +// "wrong tenant or insufficient RBAC" suggestion — surfacing the same +// failure mode here would double-report and dilute the diagnosis. +// +// The function never logs the raw token. JWT parsing is delegated to +// `extractUPN`, which returns empty on any decode failure (the token +// is still valid; the check just renders without the identifier). +func realProbeAuth(ctx context.Context) authProbeResult { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return authProbeResult{err: fmt.Errorf("create credential: %w", err)} + } + tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{authScope}, + }) + if err != nil { + return authProbeResult{err: err} + } + return authProbeResult{ + upn: extractUPN(tok.Token), + validFor: time.Until(tok.ExpiresOn), + } +} + +// extractUPN best-effort decodes a JWT's payload and returns the first +// non-empty UPN-like claim. Order: `upn`, `unique_name`, +// `preferred_username`, `email`. Returns "" on any parse error or +// when none of the claims are present — never an error: the auth +// check cares about the token's validity, not how readable its +// claims are. The raw token is never returned, logged, or otherwise +// exposed by this function. +func extractUPN(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + for _, key := range []string{"upn", "unique_name", "preferred_username", "email"} { + if v, ok := claims[key].(string); ok { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + } + return "" +} + +// composeAuthMessage formats the user-visible Message for the auth +// check, prepending the UPN (when known) so the report identifies the +// authenticated identity at a glance — matching the design's example +// " · token valid for minutes". +func composeAuthMessage(upn, body string) string { + if upn == "" { + return body + } + return upn + " · " + body +} + +// redactUPN returns the value to surface in user-facing messages: the +// raw UPN when --unredacted, the shared placeholder when a +// UPN was discovered but should be scrubbed, and empty when none was +// found (drops the prefix in composeAuthMessage). +func redactUPN(upn string, unredacted bool) string { + if upn == "" { + return "" + } + if unredacted { + return upn + } + return redactedPlaceholder +} + +// authDetails builds the structured Details map for the auth check. +// The raw UPN only appears when --unredacted is set; otherwise the +// key is omitted entirely so machine consumers do not see the value. +func authDetails(upn string, minutes int, unredacted bool) map[string]any { + details := map[string]any{"validForMinutes": minutes} + if unredacted && upn != "" { + details["upn"] = upn + } + return details +} + +// formatMinutes renders a minute count with the correct singular/plural unit. +func formatMinutes(n int) string { + if n == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", n) +} + +// formatTokenWindow renders a positive validity duration for the +// user-visible Pass / Warn messages. For sub-minute windows we +// substitute "less than 1 minute" so the message can never read +// "0 minutes" — that wording is indistinguishable from expiry to a +// reader scanning the report quickly and would obscure the Warn +// severity. Sub-second windows are rounded up to the same bucket. +// Callers must have already classified `<= 0` as Fail before calling +// this function. +func formatTokenWindow(d time.Duration) string { + if d < time.Minute { + return "less than 1 minute" + } + return formatMinutes(int(d.Minutes())) +} + +// firstLine returns s up to the first newline (exclusive) with any +// trailing carriage return stripped. Used to elide multi-line stack +// traces returned by azidentity (which on Windows commonly uses CRLF +// because `azd` is invoked via `os/exec`); the doctor report should +// be one line per failure, and the trailing suggestion already tells +// the user what to do. +func firstLine(s string) string { + if before, _, ok := strings.Cut(s, "\n"); ok { + return strings.TrimRight(before, "\r") + } + return s +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_auth_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_auth_test.go new file mode 100644 index 00000000000..bdb8133c01d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_auth_test.go @@ -0,0 +1,581 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// makeFakeJWT builds a minimally valid JWT-shaped token whose middle +// segment decodes to the given claims map. Used by the UPN-extraction +// tests so we don't need to mint a real Azure AD token. The header / +// signature segments are placeholder bytes; the auth check only reads +// the payload. +func makeFakeJWT(t *testing.T, claims map[string]any) string { + t.Helper() + payload, err := json.Marshal(claims) + require.NoError(t, err) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + body := base64.RawURLEncoding.EncodeToString(payload) + return header + "." + body + ".sig" +} + +// authProbeStub builds a doctor.Dependencies whose probeAuth seam +// returns the supplied authProbeResult. Centralizing this here keeps +// each test focused on its branch of the check's logic. +func authProbeStub(res authProbeResult) Dependencies { + return Dependencies{ + probeAuth: func(_ context.Context) authProbeResult { return res }, + } +} + +func TestCheckAuth_SkipsWhenEnvironmentSelectedFailed(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + err: errors.New("probe should not have been called"), + })) + prior := []Result{{ + ID: "local.environment-selected", + Status: StatusFail, + }} + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "select an azd environment") + require.Contains(t, got.Message, "local.environment-selected") +} + +func TestCheckAuth_SkipsWhenEnvironmentSelectedSkipped(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + err: errors.New("probe should not have been called"), + })) + prior := []Result{{ + ID: "local.environment-selected", + Status: StatusSkip, + }} + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status, + "priorBlocked treats Skip the same as Fail for cascade purposes") +} + +func TestCheckAuth_RunsWhenEnvironmentSelectedPassed(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: 47 * time.Minute, + })) + prior := []Result{{ + ID: "local.environment-selected", + Status: StatusPass, + }} + + got := check.Fn(t.Context(), Options{Unredacted: true}, prior) + + require.Equal(t, StatusPass, got.Status) + require.Equal(t, "user@contoso.com · token valid for 47 minutes", got.Message) + require.Equal(t, 47, got.Details["validForMinutes"]) + require.Equal(t, "user@contoso.com", got.Details["upn"]) +} + +func TestCheckAuth_FailsOnTokenAcquisitionError(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + err: errors.New("DefaultAzureCredential: failed to acquire a token"), + })) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "token acquisition failed") + require.Contains(t, got.Message, "failed to acquire a token") + require.Equal(t, "Run `azd auth login` to authenticate.", got.Suggestion) + require.NotEmpty(t, got.Links) +} + +func TestCheckAuth_FailErrorMessageStripsTrailingLines(t *testing.T) { + t.Parallel() + + multi := "primary cause\nstack frame 1\nstack frame 2" + check := newCheckAuth(authProbeStub(authProbeResult{err: errors.New(multi)})) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Contains(t, got.Message, "primary cause") + require.NotContains(t, got.Message, "stack frame 1", + "firstLine should elide multi-line stack traces from the report") +} + +func TestCheckAuth_FailsOnExpiredToken(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: -2 * time.Minute, + })) + + got := check.Fn(t.Context(), Options{Unredacted: true}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "user@contoso.com") + require.Contains(t, got.Message, "expired") + require.Equal(t, "Run `azd auth login` to refresh the token.", got.Suggestion) + require.NotEmpty(t, got.Links, + "every Fail branch that suggests `azd auth login` must include the reference link") +} + +func TestCheckAuth_WarnsWhenTokenExpiresSoon(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: 2 * time.Minute, + })) + + got := check.Fn(t.Context(), Options{Unredacted: true}, nil) + + require.Equal(t, StatusWarn, got.Status) + require.Contains(t, got.Message, "token expires in 2 minutes") + require.Contains(t, got.Suggestion, "Run `azd auth login`") + require.Equal(t, 2, got.Details["validForMinutes"]) + require.Equal(t, "user@contoso.com", got.Details["upn"]) +} + +func TestCheckAuth_WarnsAtExactlyOneMinute(t *testing.T) { + t.Parallel() + + // Exercise the singular-unit branch in formatMinutes alongside + // the < 5 minute warn threshold. + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: 90 * time.Second, // int(Minutes()) == 1 + })) + + got := check.Fn(t.Context(), Options{Unredacted: true}, nil) + + require.Equal(t, StatusWarn, got.Status) + require.Contains(t, got.Message, "token expires in 1 minute") + require.NotContains(t, got.Message, "1 minutes") +} + +// TestCheckAuth_WarnSubMinuteRendersLessThanOneMinute guards against +// the rendering bug where `int((30s).Minutes()) == 0` would surface +// as "token expires in 0 minutes" — indistinguishable from expiry to +// a reader scanning the report quickly. formatTokenWindow substitutes +// "less than 1 minute" for any sub-minute positive window so the Warn +// severity stays legible. +func TestCheckAuth_WarnSubMinuteRendersLessThanOneMinute(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: 30 * time.Second, + })) + + got := check.Fn(t.Context(), Options{Unredacted: true}, nil) + + require.Equal(t, StatusWarn, got.Status, + "30s of validity is positive — must be Warn, not Fail") + require.Contains(t, got.Message, "less than 1 minute") + require.NotContains(t, got.Message, "0 minutes", + "sub-minute windows must not render as `0 minutes` (ambiguous with expiry)") +} + +// TestCheckAuth_WarnPassBoundaryAtFiveMinutes pins the < / >= split +// at the authWarnThreshold so a future refactor from `<` to `<=` +// can't silently demote a Pass into a Warn. +func TestCheckAuth_WarnPassBoundaryAtFiveMinutes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + validFor time.Duration + want Status + }{ + {"just under 5m is Warn", 5*time.Minute - 1, StatusWarn}, + {"exactly 5m is Pass", 5 * time.Minute, StatusPass}, + {"just over 5m is Pass", 5*time.Minute + 1, StatusPass}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: tc.validFor, + })) + got := check.Fn(t.Context(), Options{Unredacted: true}, nil) + require.Equal(t, tc.want, got.Status) + }) + } +} + +// TestCheckAuth_SkipsOnUserCancellation proves that a cancelled outer +// context maps the probe error to a Skip (not a Fail with +// `azd auth login`). Without this branch, hitting Ctrl-C during the +// doctor command would leave the user with a misleading "auth broken" +// diagnosis. +func TestCheckAuth_SkipsOnUserCancellation(t *testing.T) { + t.Parallel() + + check := newCheckAuth(Dependencies{ + probeAuth: func(ctx context.Context) authProbeResult { + <-ctx.Done() + return authProbeResult{err: ctx.Err()} + }, + }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + got := check.Fn(ctx, Options{}, nil) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "cancelled") + require.NotContains(t, got.Suggestion, "azd auth login", + "cancellation must NOT recommend `azd auth login`") +} + +// TestCheckAuth_FailsOnProbeTimeoutWithDistinctMessage proves that +// deadline-exceeded errors surface as a Fail with a timeout-specific +// message (not "token acquisition failed: context deadline exceeded" +// which falsely implies an auth problem). +func TestCheckAuth_FailsOnProbeTimeoutWithDistinctMessage(t *testing.T) { + t.Parallel() + + check := newCheckAuth(Dependencies{ + probeAuth: func(ctx context.Context) authProbeResult { + // Simulate the probe noticing the parent deadline. + return authProbeResult{err: context.DeadlineExceeded} + }, + }) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "timed out") + require.NotContains(t, got.Message, "token acquisition failed", + "timeout must not be reported as a generic acquisition failure") + require.Contains(t, got.Suggestion, "Retry `azd ai agent doctor`") +} + +func TestCheckAuth_PassesWithoutUPN(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + validFor: 60 * time.Minute, + })) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Equal(t, "token valid for 60 minutes", got.Message, + "with no UPN the message should not have the ` · ` separator") +} + +// TestCheckAuth_RedactsUPNByDefault pins the doctor redaction contract for +// the auth check: with --unredacted absent (Options{Unredacted: false}) the +// raw UPN must not appear in Message or Details on any of the PASS / WARN / +// expired-FAIL branches that surface it. The placeholder substitutes so +// readers can still see that a UPN was discovered. +func TestCheckAuth_RedactsUPNByDefault(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + validFor time.Duration + wantStatus Status + }{ + {"pass branch", 60 * time.Minute, StatusPass}, + {"warn branch", 2 * time.Minute, StatusWarn}, + {"expired fail branch", -2 * time.Minute, StatusFail}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: tc.validFor, + })) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, tc.wantStatus, got.Status) + require.NotContains(t, got.Message, "user@contoso.com", + "raw UPN must not appear in Message without --unredacted") + require.Contains(t, got.Message, redactedPlaceholder, + "redacted placeholder must signal that a UPN was found") + if tc.wantStatus != StatusFail { + require.NotContains(t, got.Details, "upn", + "raw UPN must not appear in Details without --unredacted") + } + }) + } +} + +// TestCheckAuth_UnredactedKeepsUPN confirms the --unredacted flag still +// surfaces the raw UPN in both Message and Details on the same branches. +func TestCheckAuth_UnredactedKeepsUPN(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + validFor time.Duration + wantStat Status + }{ + {"pass branch", 60 * time.Minute, StatusPass}, + {"warn branch", 2 * time.Minute, StatusWarn}, + {"expired fail branch", -2 * time.Minute, StatusFail}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + check := newCheckAuth(authProbeStub(authProbeResult{ + upn: "user@contoso.com", + validFor: tc.validFor, + })) + + got := check.Fn(t.Context(), Options{Unredacted: true}, nil) + + require.Equal(t, tc.wantStat, got.Status) + require.Contains(t, got.Message, "user@contoso.com") + require.NotContains(t, got.Message, redactedPlaceholder) + if tc.wantStat != StatusFail { + require.Equal(t, "user@contoso.com", got.Details["upn"]) + } + }) + } +} + +// TestCheckAuth_RedactionWithoutUPNDropsPrefix ensures the redacted +// placeholder is not added when no UPN was found at all — the message +// must remain "token valid for 60 minutes" without any " · " separator. +func TestCheckAuth_RedactionWithoutUPNDropsPrefix(t *testing.T) { + t.Parallel() + + check := newCheckAuth(authProbeStub(authProbeResult{ + validFor: 60 * time.Minute, + })) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Equal(t, "token valid for 60 minutes", got.Message) + require.NotContains(t, got.Message, redactedPlaceholder, + "no placeholder should appear when no UPN was found") + require.NotContains(t, got.Details, "upn") +} + +func TestCheckAuth_UsesDefaultProbeWhenSeamNotInjected(t *testing.T) { + t.Parallel() + + // When deps.probeAuth is nil the check must fall back to + // realProbeAuth — i.e. the closure must not panic on the nil + // function value. We feed an already-cancelled ctx so the call + // returns quickly regardless of the host's auth state, and we + // assert the cancellation classification kicks in (StatusSkip) + // rather than relying on whatever azd-login state the test host + // happens to be in. + check := newCheckAuth(Dependencies{}) + require.NotNil(t, check.Fn) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + got := check.Fn(ctx, Options{}, nil) + + require.Equal(t, StatusSkip, got.Status, + "cancelled ctx must classify as Skip even via the default probe") + require.Contains(t, got.Message, "cancelled") +} + +// ---- extractUPN ---- + +func TestExtractUPN_PrefersUpnClaim(t *testing.T) { + t.Parallel() + + tok := makeFakeJWT(t, map[string]any{ + "upn": "alice@contoso.com", + "unique_name": "alice.unique", + "preferred_username": "alice.preferred", + "email": "alice.email", + }) + + require.Equal(t, "alice@contoso.com", extractUPN(tok)) +} + +func TestExtractUPN_FallsThroughClaims(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + claims map[string]any + want string + }{ + { + name: "unique_name when upn missing", + claims: map[string]any{"unique_name": "u1@contoso.com", "email": "x@contoso.com"}, + want: "u1@contoso.com", + }, + { + name: "preferred_username when upn and unique_name missing", + claims: map[string]any{"preferred_username": "u2@contoso.com", "email": "x@contoso.com"}, + want: "u2@contoso.com", + }, + { + name: "email as last resort", + claims: map[string]any{"email": "u3@contoso.com"}, + want: "u3@contoso.com", + }, + { + name: "empty upn skipped, falls to next claim", + claims: map[string]any{"upn": "", "unique_name": "u4@contoso.com"}, + want: "u4@contoso.com", + }, + { + name: "whitespace upn skipped, falls to next claim", + claims: map[string]any{"upn": " ", "email": "u5@contoso.com"}, + want: "u5@contoso.com", + }, + { + name: "no UPN-like claims", + claims: map[string]any{"sub": "abc", "aud": "xyz"}, + want: "", + }, + { + name: "non-string upn claim is ignored", + claims: map[string]any{"upn": 12345, "email": "u6@contoso.com"}, + want: "u6@contoso.com", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, extractUPN(makeFakeJWT(t, tc.claims))) + }) + } +} + +func TestExtractUPN_HandlesMalformedTokens(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + tok string + }{ + {name: "empty token", tok: ""}, + {name: "one segment", tok: "abc"}, + {name: "two segments", tok: "abc.def"}, + {name: "four segments", tok: "a.b.c.d"}, + {name: "invalid base64 payload", tok: "header.!!!notbase64!!!.sig"}, + {name: "valid base64 but not JSON", tok: "header." + + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".sig"}, + {name: "JSON array instead of object", tok: "header." + + base64.RawURLEncoding.EncodeToString([]byte(`["array"]`)) + ".sig"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Empty(t, extractUPN(tc.tok), + "malformed token must return empty UPN, never panic") + }) + } +} + +// ---- formatMinutes & helpers ---- + +func TestFormatMinutes(t *testing.T) { + t.Parallel() + + require.Equal(t, "0 minutes", formatMinutes(0)) + require.Equal(t, "1 minute", formatMinutes(1)) + require.Equal(t, "2 minutes", formatMinutes(2)) + require.Equal(t, "60 minutes", formatMinutes(60)) +} + +// TestFormatTokenWindow pins the sub-minute substitution that keeps +// the Warn message legible. Anything `>= 1 minute` falls through to +// formatMinutes' regular rendering; anything `< 1 minute` (assumed +// positive — callers must classify `<= 0` as Fail before this) maps +// to a fixed "less than 1 minute" string. The Pass / Warn branches in +// newCheckAuth both rely on this contract. +func TestFormatTokenWindow(t *testing.T) { + t.Parallel() + + require.Equal(t, "less than 1 minute", formatTokenWindow(time.Second)) + require.Equal(t, "less than 1 minute", formatTokenWindow(59*time.Second)) + require.Equal(t, "1 minute", formatTokenWindow(time.Minute)) + require.Equal(t, "1 minute", formatTokenWindow(90*time.Second)) + require.Equal(t, "47 minutes", formatTokenWindow(47*time.Minute)) +} + +func TestComposeAuthMessage(t *testing.T) { + t.Parallel() + + require.Equal(t, "token valid for 5 minutes", + composeAuthMessage("", "token valid for 5 minutes")) + require.Equal(t, "alice@contoso.com · token valid for 5 minutes", + composeAuthMessage("alice@contoso.com", "token valid for 5 minutes")) +} + +func TestRedactUPN(t *testing.T) { + t.Parallel() + + require.Equal(t, "", redactUPN("", false)) + require.Equal(t, "", redactUPN("", true)) + require.Equal(t, redactedPlaceholder, redactUPN("alice@contoso.com", false)) + require.Equal(t, "alice@contoso.com", redactUPN("alice@contoso.com", true)) +} + +func TestAuthDetails(t *testing.T) { + t.Parallel() + + redacted := authDetails("alice@contoso.com", 42, false) + require.Equal(t, 42, redacted["validForMinutes"]) + require.NotContains(t, redacted, "upn", + "upn key must be omitted when --unredacted is false") + + unredacted := authDetails("alice@contoso.com", 42, true) + require.Equal(t, 42, unredacted["validForMinutes"]) + require.Equal(t, "alice@contoso.com", unredacted["upn"]) + + missing := authDetails("", 42, true) + require.Equal(t, 42, missing["validForMinutes"]) + require.NotContains(t, missing, "upn", + "upn key must be omitted when no UPN was found, even with --unredacted") +} + +func TestFirstLine(t *testing.T) { + t.Parallel() + + require.Equal(t, "single line", firstLine("single line")) + require.Equal(t, "first", firstLine("first\nsecond\nthird")) + // Windows / CRLF case: azidentity invokes `azd` via os/exec and + // stderr output on Windows commonly arrives with \r\n line endings. + // firstLine must strip the trailing \r so terminal renderers don't + // drop or garble the doctor message. + require.Equal(t, "first", firstLine("first\r\nsecond")) + require.Equal(t, "trailing \\r alone\r", firstLine("trailing \\r alone\r"), + "a lone trailing \\r without a following \\n is left in place — "+ + "only newline-stripped lines get the \\r trimmed") + require.Equal(t, "", firstLine("\ntrailing"), + "empty-first-line is preserved — caller's responsibility") + require.Equal(t, "", firstLine("")) + require.Equal(t, "no newline at end", + firstLine(strings.TrimRight("no newline at end\n", "\n"))) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections.go new file mode 100644 index 00000000000..455a927079b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections.go @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "azureaiagent/internal/cmd/nextstep" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// foundryConnectionsProbeTimeout caps the per-project connections +// list round trip. Same 10s budget as model-deployments — the design +// doc allocates that envelope per remote probe so a stuck VPN or +// transient Foundry hiccup never drags the whole doctor run. +const foundryConnectionsProbeTimeout = 10 * time.Second + +// foundryConnectionsProbeFn is the seam-friendly signature for the +// project connections list probe. The closure receives the account +// + project identifiers needed by `FoundryProjectsClient` and +// returns the connection names that exist on the project. Errors +// short-circuit the check to Skip — a 401/403/network failure has +// the same surface as "Foundry unreachable" which the upstream +// `remote.foundry-endpoint` already classifies, so silencing here +// keeps the report from emitting two near-identical Fail lines. +type foundryConnectionsProbeFn func( + ctx context.Context, + accountName, projectName string, +) ([]string, error) + +// newCheckConnections produces Check `remote.connections` (P5.1 +// C15). For each `ConnectionResource` declared in any service's +// `agent.manifest.yaml` (collected by the C2 manifest walker), the +// check queries the Foundry project's connection list and verifies a +// connection with the matching name exists. The check Passes when +// every manifest-declared connection has a corresponding entry; +// Fails when one or more are missing. +// +// # Skip cascade +// +// - deps.AzdClient nil → upstream `local.grpc-extension` already +// surfaced the actionable error. +// - `local.environment-selected` failed/skipped → nothing to read +// state from. +// - `local.azure-yaml` / `local.agent-service-detected` failed → +// no services to walk; would Pass falsely if we forged ahead. +// - `remote.auth` failed → Foundry list would 401 identically; +// let the auth check own the diagnosis. +// - `remote.foundry-endpoint` failed → same root cause, same +// remediation. +// - state.HasConnections == false → no manifest connection +// declarations; the check has nothing to verify. Surface as +// Skip with a short explanation rather than a vacuous Pass. +// - `AZURE_AI_PROJECT_ID` not set / cannot be parsed → can not +// derive the account + project to probe. Skip cleanly; the +// rbac check already emits the canonical `azd env set` fix. +// +// # Classification +// +// - Every manifest connection matches a Foundry connection name → +// Pass with the matched count. +// - One or more missing → Fail with the missing names listed in +// the Message and structured under `Details["missingConnections"]` +// (each entry carries Name, ServiceName, Detail — the manifest's +// " | " identifier surfaced by the C2 walker). +// - Probe error → Skip with the underlying error verbatim. +func newCheckConnections(deps Dependencies) Check { + return Check{ + ID: "remote.connections", + Name: "Manifest connections exist on Foundry project", + Remote: true, + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable.", + } + } + if priorBlocked(prior, "local.environment-selected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no azd environment is selected " + + "(see check `local.environment-selected`).", + } + } + if priorBlocked(prior, "local.azure-yaml") || + priorBlocked(prior, "local.agent-service-detected") { + return Result{ + Status: StatusSkip, + Message: "skipped: azure.yaml / agent service detection failed " + + "(see checks `local.azure-yaml`, `local.agent-service-detected`).", + } + } + if priorBlocked(prior, "remote.auth") { + return Result{ + Status: StatusSkip, + Message: "skipped: auth probe did not succeed " + + "(see check `remote.auth`).", + } + } + if priorBlocked(prior, "remote.foundry-endpoint") { + return Result{ + Status: StatusSkip, + Message: "skipped: Foundry project endpoint unreachable " + + "(see check `remote.foundry-endpoint`).", + } + } + + assembler := deps.assembleState + if assembler == nil { + assembler = func(c context.Context, client *azdext.AzdClient) (*nextstep.State, []error) { + return nextstep.AssembleState(c, client) + } + } + state, errs := assembler(ctx, deps.AzdClient) + if state == nil { + cause := "unknown error" + if len(errs) > 0 { + cause = errs[0].Error() + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to assemble agent state: %s", cause), + Suggestion: "Re-run `azd ai agent doctor`; the state assembly returned nil unexpectedly.", + } + } + if !state.HasConnections { + return Result{ + Status: StatusSkip, + Message: "skipped: no connection resources declared in any service's agent.manifest.yaml.", + } + } + + projectIDReader := deps.readProjectResourceIDFn + if projectIDReader == nil { + projectIDReader = readProjectResourceID + } + projectID, err := projectIDReader(ctx, deps.AzdClient) + if err != nil || projectID == "" { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: %s is not set in the current azd environment "+ + "(see check `remote.rbac`).", projectIDVar), + } + } + + account, project, err := parseAccountProjectFromProjectID(projectID) + if err != nil { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: could not parse account / project from %s (%s).", + projectIDVar, err), + } + } + + probe := deps.probeFoundryConnections + if probe == nil { + probe = realProbeFoundryConnections + } + + probeCtx, cancel := context.WithTimeout(ctx, foundryConnectionsProbeTimeout) + defer cancel() + + connections, err := probe(probeCtx, account, project) + if err != nil { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: could not list connections under project %s (%s).", + project, err), + Suggestion: "Retry `azd ai agent doctor`. If the error persists, " + + "verify network reachability to Foundry and that your azd " + + "login has read access to the project.", + } + } + + return classifyConnections(state.Connections, connections, account, project) + }, + } +} + +// parseAccountProjectFromProjectID extracts (accountName, projectName) +// from a Foundry project ARM resource ID of the form +// +// /subscriptions//resourceGroups//providers/ +// Microsoft.CognitiveServices/accounts//projects/ +// +// Sibling of `parseAccountFromProjectID` (C13) — left as a separate +// helper so the C13 signature does not churn for a single new +// caller. Both parsers are case-insensitive on segment markers +// because ARM occasionally normalizes casing on round-trip. +func parseAccountProjectFromProjectID(projectID string) (account, project string, err error) { + parts := strings.Split(projectID, "/") + for i := 0; i+1 < len(parts); i++ { + switch strings.ToLower(parts[i]) { + case "accounts": + account = parts[i+1] + case "projects": + project = parts[i+1] + } + } + if account == "" || project == "" { + return "", "", fmt.Errorf("missing account / project in %q", projectID) + } + return account, project, nil +} + +// classifyConnections produces the Pass/Fail Result by joining the +// manifest's `state.Connections` to the connection names returned by +// the Foundry project. Match is on connection name only — credential +// type / target compatibility surfaces at runtime. +// +// `account` / `project` are forwarded only for human-readable strings +// in the Message; redaction is not applied because both values are +// the same identifiers the user typed into +// `azd env set AZURE_AI_PROJECT_ID` and are not considered sensitive. +func classifyConnections( + refs []nextstep.ResourceRef, + foundryConnections []string, + account, project string, +) Result { + existing := make(map[string]struct{}, len(foundryConnections)) + for _, name := range foundryConnections { + existing[name] = struct{}{} + } + + type missingEntry struct { + Name string `json:"name"` + ServiceName string `json:"service"` + Detail string `json:"detail,omitempty"` + } + + var missing []missingEntry + matched := 0 + for _, ref := range refs { + if _, ok := existing[ref.Name]; ok { + matched++ + continue + } + missing = append(missing, missingEntry{ + Name: ref.Name, + ServiceName: ref.ServiceName, + Detail: ref.Detail, + }) + } + + sort.Slice(missing, func(i, j int) bool { + if missing[i].Name != missing[j].Name { + return missing[i].Name < missing[j].Name + } + return missing[i].ServiceName < missing[j].ServiceName + }) + + if len(missing) == 0 { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "all %d referenced connection(s) present on project %s.", + matched, project), + Details: map[string]any{ + "matchedCount": matched, + "account": account, + "project": project, + }, + } + } + + var sb strings.Builder + for i, m := range missing { + if i > 0 { + sb.WriteString(", ") + } + if m.Detail != "" { + sb.WriteString(fmt.Sprintf("%s [%s] (service %s)", m.Name, m.Detail, m.ServiceName)) + } else { + sb.WriteString(fmt.Sprintf("%s (service %s)", m.Name, m.ServiceName)) + } + } + + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d connection(s) referenced by agent.manifest.yaml are missing on project %s: %s", + len(missing), project, sb.String()), + Suggestion: "Run `azd provision` to create the missing connection(s), " + + "or update the agent.manifest.yaml `resources[].name` entries to " + + "match connections that already exist on the Foundry project.", + Details: map[string]any{ + "missingConnections": missing, + "matchedCount": matched, + "account": account, + "project": project, + }, + } +} + +// realProbeFoundryConnections lists every connection on a Foundry +// project using the same `FoundryProjectsClient.GetAllConnections` +// path that production callers (init / listen) use. The function is +// the production wiring of `foundryConnectionsProbeFn`; tests inject +// a fake via `deps.probeFoundryConnections` so they don't need a +// live azd auth session. +// +// The returned slice contains connection names only; nothing else is +// surfaced because the doctor only needs name-based matching. A +// non-nil error from the client short-circuits with the wrapped +// error; the check classifies any non-nil error as Skip. +func realProbeFoundryConnections( + ctx context.Context, + accountName, projectName string, +) ([]string, error) { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("create credential: %w", err) + } + return listFoundryConnectionNames(ctx, cred, accountName, projectName) +} + +// listFoundryConnectionNames is the credential-injecting variant of +// realProbeFoundryConnections, factored out so tests that supply a +// fake `azcore.TokenCredential` can exercise the client wiring +// without going through `azd auth`. The function pages through every +// connection on the project (via `GetAllConnections`) and returns +// the `Name` field of each. +func listFoundryConnectionNames( + ctx context.Context, + credential azcore.TokenCredential, + accountName, projectName string, +) ([]string, error) { + client, err := azure.NewFoundryProjectsClient(accountName, projectName, credential) + if err != nil { + return nil, fmt.Errorf("create Foundry projects client: %w", err) + } + conns, err := client.GetAllConnections(ctx) + if err != nil { + return nil, fmt.Errorf("list Foundry connections: %w", err) + } + names := make([]string, 0, len(conns)) + for _, c := range conns { + names = append(names, c.Name) + } + return names, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections_test.go new file mode 100644 index 00000000000..834ade78b00 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections_test.go @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// fixedConnectionsProbe returns a foundryConnectionsProbeFn that +// yields the supplied (names, err). Captures the args it was called +// with via the pointer for in-test assertion of probe routing. +func fixedConnectionsProbe( + names []string, err error, captured *[]string, +) foundryConnectionsProbeFn { + return func(_ context.Context, account, project string) ([]string, error) { + if captured != nil { + *captured = []string{account, project} + } + return names, err + } +} + +func runConnectionsCheck(t *testing.T, deps Dependencies, prior []Result) Result { + t.Helper() + if deps.AzdClient == nil { + deps.AzdClient = &azdext.AzdClient{} + } + c := newCheckConnections(deps) + require.NotNil(t, c.Fn) + require.Equal(t, "remote.connections", c.ID) + require.True(t, c.Remote, "connections check must be tagged remote") + return c.Fn(t.Context(), Options{}, prior) +} + +// healthyConnectionsPrior returns the canonical "all upstream checks +// passed" prior slice that lets the connections check reach its own +// classification logic. Same shape as healthyModelPrior used by C13. +func healthyConnectionsPrior() []Result { + return []Result{ + {ID: "local.azure-yaml", Status: StatusPass}, + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusPass}, + } +} + +// ---- Skip-cascade gates ---- + +func TestCheckConnections_SkipsWhenAzdClientNil(t *testing.T) { + t.Parallel() + c := newCheckConnections(Dependencies{}) + res := c.Fn(t.Context(), Options{}, nil) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "azd extension not reachable") +} + +func TestCheckConnections_SkipsCascadeFromUpstream(t *testing.T) { + t.Parallel() + tests := []struct { + name string + failedID string + wantHint string + }{ + {"environment selected blocked", "local.environment-selected", "local.environment-selected"}, + {"azure.yaml blocked", "local.azure-yaml", "local.azure-yaml"}, + {"agent service detected blocked", "local.agent-service-detected", "local.agent-service-detected"}, + {"auth blocked", "remote.auth", "remote.auth"}, + {"foundry-endpoint blocked", "remote.foundry-endpoint", "remote.foundry-endpoint"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + prior := []Result{{ID: tc.failedID, Status: StatusFail}} + res := runConnectionsCheck(t, Dependencies{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, tc.wantHint) + }) + } +} + +// ---- State emptiness ---- + +func TestCheckConnections_SkipsWhenNoManifestConnections(t *testing.T) { + t.Parallel() + deps := Dependencies{ + assembleState: fixedAssembler(&nextstep.State{HasConnections: false}), + probeFoundryConnections: fixedConnectionsProbe(nil, nil, nil), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "no connection resources declared") +} + +func TestCheckConnections_FailsWhenAssemblerReturnsNilState(t *testing.T) { + t.Parallel() + deps := Dependencies{ + assembleState: func(_ context.Context, _ *azdext.AzdClient) (*nextstep.State, []error) { + return nil, []error{errors.New("manifest.walker: parse error")} + }, + probeFoundryConnections: fixedConnectionsProbe(nil, nil, nil), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "failed to assemble agent state") + require.Contains(t, res.Message, "parse error", + "assembler errs[0] should surface in the Fail message") +} + +func TestCheckConnections_SkipsWhenProjectIDUnset(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasConnections: true, + Connections: []nextstep.ResourceRef{ + {Name: "blob-storage", ServiceName: "chat", Detail: "AzureBlob | account"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader("", errors.New("not set")), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "AZURE_AI_PROJECT_ID") +} + +func TestCheckConnections_SkipsWhenProjectIDUnparsable(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasConnections: true, + Connections: []nextstep.ResourceRef{ + {Name: "blob-storage", ServiceName: "chat"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader("garbage", nil), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "could not parse account / project") +} + +func TestCheckConnections_SkipsWhenProbeErrors(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasConnections: true, + Connections: []nextstep.ResourceRef{ + {Name: "blob-storage", ServiceName: "chat"}, + }, + } + var captured []string + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeFoundryConnections: fixedConnectionsProbe( + nil, errors.New("Foundry transient"), &captured), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "Foundry transient") + require.NotEmpty(t, res.Suggestion, "transport-error skip must surface retry guidance") + require.Equal(t, + []string{"acct-1", "proj-1"}, + captured, + "probe must receive accountName / projectName parsed from project ID") +} + +// ---- Classification ---- + +func TestCheckConnections_PassesWhenAllRefsMatch(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasConnections: true, + Connections: []nextstep.ResourceRef{ + {Name: "blob-storage", ServiceName: "chat", Detail: "AzureBlob | acct"}, + {Name: "openai-default", ServiceName: "chat", Detail: "AzureOpenAI | https://openai.test"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeFoundryConnections: fixedConnectionsProbe( + []string{"blob-storage", "openai-default", "unrelated-other"}, + nil, nil), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusPass, res.Status) + require.Contains(t, res.Message, "all 2 referenced connection(s) present") + require.Contains(t, res.Message, "proj-1") + require.EqualValues(t, 2, res.Details["matchedCount"]) + require.Equal(t, "proj-1", res.Details["project"]) + require.Equal(t, "acct-1", res.Details["account"]) +} + +func TestCheckConnections_FailsWithMissing(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasConnections: true, + Connections: []nextstep.ResourceRef{ + {Name: "blob-storage", ServiceName: "chat", Detail: "AzureBlob | acct"}, + {Name: "openai-default", ServiceName: "chat", Detail: "AzureOpenAI | https://openai.test"}, + {Name: "search-conn", ServiceName: "search", Detail: "CognitiveSearch | search.test"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeFoundryConnections: fixedConnectionsProbe( + []string{"blob-storage"}, // only first exists + nil, nil), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "2 connection(s)") + require.Contains(t, res.Message, "openai-default [AzureOpenAI | https://openai.test] (service chat)") + require.Contains(t, res.Message, "search-conn [CognitiveSearch | search.test] (service search)") + require.NotContains(t, res.Message, "blob-storage") + require.Contains(t, res.Suggestion, "azd provision") + require.EqualValues(t, 1, res.Details["matchedCount"]) +} + +func TestCheckConnections_FailsWhenAllMissing(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasConnections: true, + Connections: []nextstep.ResourceRef{ + {Name: "blob-storage", ServiceName: "chat"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeFoundryConnections: fixedConnectionsProbe([]string{}, nil, nil), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "1 connection(s)") + require.Contains(t, res.Message, "blob-storage (service chat)") +} + +// When Detail is empty the missing-list entry omits the bracketed +// suffix instead of emitting an empty `[]`. +func TestCheckConnections_MissingEntryOmitsEmptyDetail(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasConnections: true, + Connections: []nextstep.ResourceRef{ + {Name: "anon-conn", ServiceName: "chat"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeFoundryConnections: fixedConnectionsProbe(nil, nil, nil), + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "anon-conn (service chat)") + require.NotContains(t, res.Message, "[]") +} + +// ---- Parser ---- + +func TestParseAccountProjectFromProjectID(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantAccount string + wantProject string + wantError bool + }{ + { + name: "canonical case", + input: validProjectResourceID, + wantAccount: "acct-1", + wantProject: "proj-1", + }, + { + name: "mixed-case segment markers", + input: "/SUBSCRIPTIONS/sub-1/RESOURCEGROUPS/rg-1" + + "/providers/Microsoft.CognitiveServices/ACCOUNTS/acct-2/PROJECTS/p-2", + wantAccount: "acct-2", + wantProject: "p-2", + }, + { + name: "missing project segment", + input: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/a", + wantError: true, + }, + { + name: "missing account segment", + input: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.CognitiveServices/projects/p", + wantError: true, + }, + { + name: "garbage input", + input: "not-a-resource-id", + wantError: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + account, project, err := parseAccountProjectFromProjectID(tc.input) + if tc.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantAccount, account) + require.Equal(t, tc.wantProject, project) + }) + } +} + +// ---- Factory wiring ---- + +func TestNewCheckConnections_FactoryShape(t *testing.T) { + t.Parallel() + c := newCheckConnections(Dependencies{}) + require.Equal(t, "remote.connections", c.ID) + require.NotEmpty(t, c.Name) + require.True(t, c.Remote, "connections check must be tagged remote so --local-only suppresses it") + require.NotNil(t, c.Fn) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_foundry_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_foundry_endpoint.go new file mode 100644 index 00000000000..0b22040e905 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_foundry_endpoint.go @@ -0,0 +1,458 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// foundryProbeTimeout caps the per-probe HTTP round trip. The design +// (`.tmp/pr-8057/azd-ai-agent-doctor-remote-checks.md`) calls for a +// 10 s ceiling with no retries — doctor is a one-shot diagnostic, not +// a resilient client. The context is the only retry strategy: callers +// can re-run the doctor command. Setting this any longer punishes +// users on stuck DNS / VPN with multi-minute hangs. +const foundryProbeTimeout = 10 * time.Second + +// rbacLink is the canonical learn.microsoft.com link surfaced when +// the 403 branch suggests checking `remote.rbac`. Pinned here (rather +// than in checks_auth.go) so the 401 vs 403 link rationale is local +// to the file that owns the disambiguation. +const rbacLink = "https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry" + +// foundryProbeResult is the structured outcome of one Foundry +// reachability probe. The production probe (`realProbeFoundryEndpoint`) +// makes one HTTP GET and reports either the HTTP status code or the +// transport error. statusCode is 0 when the request never reached the +// server (DNS failure, TLS error, network unreachable, context +// timeout); a non-zero statusCode means we got an HTTP response and +// err captures only fatal protocol issues unrelated to the status. +// +// requestedURL is the URL we actually GET'd, redacted of any query +// strings beyond the api-version and `limit` parameters. It is +// rendered in the Pass / Fail message so the user can verify the +// probe hit the expected endpoint, while still keeping the surface +// narrow. +type foundryProbeResult struct { + statusCode int + requestedURL string + err error +} + +// newCheckFoundryEndpoint produces Check `remote.foundry-endpoint`. +// It issues one authenticated GET against +// `/agents?api-version=&limit=1` +// and maps the HTTP status code to a precise fix: +// +// - 200 — Pass: "endpoint reachable (HTTP 200)" +// - 401 — Fail: "token expired or scope mismatch"; fix: +// `azd auth login` (cross-references `remote.auth`) +// - 403 — Fail: "wrong tenant or insufficient RBAC"; fix: confirm +// active subscription/tenant matches the Foundry project, then +// see check `remote.rbac` (C16) for role assignments +// - 404 — Fail: "endpoint wrong or project gone"; fix: `azd +// provision` or `azd env set AZURE_AI_PROJECT_ENDPOINT` +// - 5xx — Fail: "Foundry returned "; fix: retry doctor; +// report to Foundry status if persistent +// - other status / transport error — Fail: render the error; +// fix: verify VPN / firewall / typo in the endpoint +// +// Skip-cascade (per design dependency matrix lines 112-117): +// +// 1. `local.project-endpoint-set` — without an endpoint there's no +// URL to probe. +// 2. `remote.auth` — without a valid token the probe would always +// 401, duplicating `remote.auth`'s diagnosis. +// +// We do NOT additionally gate on `local.environment-selected` because +// `local.project-endpoint-set` already cascades from it. Double-gating +// would surface two skip messages for a single root cause. +func newCheckFoundryEndpoint(deps Dependencies) Check { + apiVersion := deps.AgentAPIVersion + return Check{ + ID: "remote.foundry-endpoint", + Name: "Foundry project endpoint reachable", + Remote: true, + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if priorBlocked(prior, "local.project-endpoint-set") { + return Result{ + Status: StatusSkip, + Message: "skipped: AZURE_AI_PROJECT_ENDPOINT is not set " + + "(see check `local.project-endpoint-set`).", + } + } + if priorBlocked(prior, "remote.auth") { + return Result{ + Status: StatusSkip, + Message: "skipped: auth probe did not succeed " + + "(see check `remote.auth`).", + } + } + endpoint := readProjectEndpoint(prior) + if endpoint == "" { + // Defensive: project-endpoint-set passed but its + // Details didn't carry the value. Skip rather than + // guess — the design forbids guessing endpoint values. + return Result{ + Status: StatusSkip, + Message: "skipped: upstream check passed but did not " + + "surface AZURE_AI_PROJECT_ENDPOINT in its Details.", + } + } + if apiVersion == "" { + // Defensive: production wiring must populate this + // from the package-level DefaultAgentAPIVersion + // constant. If it didn't, surface a Skip with a + // clear message instead of guessing or failing + // with a confusing HTTP error. + return Result{ + Status: StatusSkip, + Message: "skipped: doctor wiring did not provide an " + + "agent API version for the probe.", + } + } + // Validate the endpoint shape BEFORE acquiring a token. + // A non-HTTPS endpoint would leak the bearer token over + // plaintext; a relative / malformed URL would either send + // the token to the wrong host or fail at request build + // time with a confusing transport error. Catch both cases + // here with a precise, actionable Fail (no probe, no + // token acquisition). + if err := validateFoundryEndpoint(endpoint); err != nil { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "AZURE_AI_PROJECT_ENDPOINT is invalid: %s.", + err), + Suggestion: fmt.Sprintf( + "Set a valid absolute HTTPS endpoint with "+ + "`azd env set AZURE_AI_PROJECT_ENDPOINT "+ + "` (currently %q).", + endpoint), + Details: map[string]any{ + "endpoint": endpoint, + "validationError": err.Error(), + }, + } + } + + probe := deps.probeFoundryEndpoint + if probe == nil { + probe = makeRealProbeFoundryEndpoint(apiVersion) + } + probeCtx, cancel := context.WithTimeout(ctx, foundryProbeTimeout) + defer cancel() + res := probe(probeCtx, endpoint) + + // Cancellation / timeout are diagnostic-side issues, not + // Foundry problems — classify them separately so the user + // gets the right next step. + if errors.Is(ctx.Err(), context.Canceled) || + errors.Is(res.err, context.Canceled) { + return Result{ + Status: StatusSkip, + Message: "skipped: Foundry reachability probe was cancelled.", + } + } + if errors.Is(probeCtx.Err(), context.DeadlineExceeded) || + errors.Is(res.err, context.DeadlineExceeded) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "Foundry endpoint did not respond within %s.", + foundryProbeTimeout), + Suggestion: "Verify your network / VPN, confirm the URL " + + "in `AZURE_AI_PROJECT_ENDPOINT`, then retry " + + "`azd ai agent doctor`.", + Details: foundryDetails(endpoint, res), + } + } + + return classifyFoundryResult(endpoint, res) + }, + } +} + +// classifyFoundryResult maps a foundryProbeResult onto a doctor +// Result, leaving the skip-cascade / cancellation / timeout branches +// to the caller. Pulled out as a free function so unit tests can +// pin the status-code → message/suggestion table directly without +// stubbing the probe. +func classifyFoundryResult(endpoint string, res foundryProbeResult) Result { + details := foundryDetails(endpoint, res) + + if res.err != nil && res.statusCode == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("could not reach %s: %s", + endpointHost(endpoint), firstLine(res.err.Error())), + Suggestion: fmt.Sprintf( + "Verify network / VPN / firewall reachability and the URL "+ + "in `AZURE_AI_PROJECT_ENDPOINT` (currently %q).", + endpoint), + Details: details, + } + } + + switch { + case res.statusCode == http.StatusOK: + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("endpoint reachable (HTTP %d)", res.statusCode), + Details: details, + } + case res.statusCode == http.StatusUnauthorized: + return Result{ + Status: StatusFail, + Message: "Foundry returned HTTP 401 (token expired or scope mismatch).", + Suggestion: "Run `azd auth login` to refresh credentials; " + + "if the issue persists, see check `remote.auth`.", + Links: []string{authLoginLink}, + Details: details, + } + case res.statusCode == http.StatusForbidden: + return Result{ + Status: StatusFail, + Message: "Foundry returned HTTP 403 " + + "(wrong tenant or insufficient RBAC).", + Suggestion: "Confirm the active azd subscription/tenant matches " + + "the Foundry project's tenant; if it does, see check " + + "`remote.rbac` for the role-assignment fix.", + Links: []string{rbacLink}, + Details: details, + } + case res.statusCode == http.StatusNotFound: + return Result{ + Status: StatusFail, + Message: "Foundry returned HTTP 404 " + + "(endpoint is wrong or the project no longer exists).", + Suggestion: "Run `azd provision` to (re)create the Foundry " + + "project, or `azd env set AZURE_AI_PROJECT_ENDPOINT " + + "` to point at an existing one.", + Details: details, + } + case res.statusCode >= 500 && res.statusCode <= 599: + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "Foundry returned HTTP %d (service-side error).", + res.statusCode), + Suggestion: "Retry `azd ai agent doctor` after a moment; if the " + + "failure persists, check the Azure AI Foundry status page.", + Details: details, + } + default: + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "Foundry returned unexpected HTTP %d.", res.statusCode), + Suggestion: "Inspect the response in `--verbose` mode and verify " + + "`AZURE_AI_PROJECT_ENDPOINT` is correct.", + Details: details, + } + } +} + +// makeRealProbeFoundryEndpoint returns the production probe closure +// for the given api-version. The api-version is read from the +// package-level DefaultAgentAPIVersion constant by the doctor +// command's Cobra wiring and passed in via +// `Dependencies.AgentAPIVersion`, so drift between the diagnostic +// and the runtime invoke flow is impossible: both pin to the same +// constant. +// +// The closure issues a single GET — no retries. It uses the same +// credential + scope as the production runtime path +// (`internal/cmd/agent_context.go:newAgentCredential` and +// `internal/pkg/agents/agent_api/operations.go`'s +// `https://ai.azure.com/.default` scope) so the doctor's diagnosis +// applies directly to what the runtime invoke flow needs. +// +// The response body is drained but not parsed: we only need the +// status code. Draining lets the underlying HTTP/2 stream be +// returned to the connection pool. The body bytes are never +// surfaced to the user — only the status code and (for transport +// errors) the error message via `firstLine`. +func makeRealProbeFoundryEndpoint(apiVersion string) func(context.Context, string) foundryProbeResult { + return func(ctx context.Context, endpoint string) foundryProbeResult { + probeURL, err := buildFoundryProbeURL(endpoint, apiVersion) + if err != nil { + return foundryProbeResult{ + err: fmt.Errorf("build probe URL: %w", err), + requestedURL: endpoint, + } + } + + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return foundryProbeResult{ + err: fmt.Errorf("create credential: %w", err), + requestedURL: probeURL, + } + } + tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{authScope}, + }) + if err != nil { + return foundryProbeResult{err: err, requestedURL: probeURL} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil) + if err != nil { + return foundryProbeResult{err: err, requestedURL: probeURL} + } + req.Header.Set("Authorization", "Bearer "+tok.Token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "azd-ai-agent-doctor") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return foundryProbeResult{err: err, requestedURL: probeURL} + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + return foundryProbeResult{ + statusCode: resp.StatusCode, + requestedURL: probeURL, + } + } +} + +// validateFoundryEndpoint enforces the minimum-safe contract that +// callers (notably `newCheckFoundryEndpoint`) need before sending a +// bearer token: the endpoint must be a well-formed absolute HTTPS URL +// with a non-empty host. This blocks two classes of bug: +// +// 1. Token over plaintext — a stray `http://...` in the env var +// would otherwise send the Foundry data-plane token over an +// unencrypted channel. +// 2. Token to the wrong host — a relative URL (`/api/projects/x`) +// or an opaque string would attach the Authorization header to +// whatever default base the HTTP client resolves, which is not +// necessarily Foundry. +// +// Returns nil if the endpoint is safe to probe. +func validateFoundryEndpoint(endpoint string) error { + trimmed := strings.TrimSpace(endpoint) + if trimmed == "" { + return errors.New("endpoint is empty") + } + u, err := url.Parse(trimmed) + if err != nil { + return fmt.Errorf("not a valid URL: %w", err) + } + if u.Scheme != "https" { + return fmt.Errorf("scheme must be https (got %q)", u.Scheme) + } + if u.Host == "" { + return errors.New("URL has no host") + } + return nil +} + +// buildFoundryProbeURL joins the user-supplied endpoint with the +// fixed `/agents` path and the canonical api-version / `limit=1` +// query parameters. It parses the endpoint FIRST and then mutates +// the parsed URL's Path / RawQuery / Fragment so user-supplied +// query strings or fragments cannot displace the `/agents` segment: +// +// - Trailing slashes on the endpoint Path are tolerated (so users +// who set `.../projects/example/` and `.../projects/example` see +// the same probe URL). +// - Any user-supplied `?api-version=foo` (or any other query +// parameter) is dropped: we overwrite RawQuery wholesale with +// the canonical pair. +// - Any user-supplied `#fragment` is dropped: fragments are never +// sent over the wire and would prevent `/agents` from landing on +// the URL Path. +// - The endpoint must be an absolute HTTPS URL with a host. The +// `newCheckFoundryEndpoint` check already validates this via +// `validateFoundryEndpoint`; the duplicate check here keeps the +// builder self-contained so callers in future remote checks +// cannot accidentally bypass the safety contract. +// +// We use `limit=1` (not `$top=1`) to match the production runtime +// client in `internal/pkg/agents/agent_api/operations.go`, so a Pass +// here proves the same query shape the runtime invoke flow uses. +func buildFoundryProbeURL(endpoint, apiVersion string) (string, error) { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return "", err + } + if u.Scheme != "https" || u.Host == "" { + return "", fmt.Errorf("endpoint must be an absolute https URL") + } + u.Path = strings.TrimRight(u.Path, "/") + "/agents" + u.Fragment = "" + u.RawFragment = "" + q := url.Values{} + q.Set("api-version", apiVersion) + q.Set("limit", "1") + u.RawQuery = q.Encode() + return u.String(), nil +} + +// endpointHost returns the host portion of an endpoint URL for use +// in user-visible messages where rendering the full URL (with query +// string) would be noisy. Returns the input verbatim if parsing +// yields an empty host (relative URL, opaque string, or — rarely — +// a genuinely malformed scheme). We'd rather surface a slightly +// ugly message than swallow information when the user is debugging. +func endpointHost(endpoint string) string { + u, err := url.Parse(endpoint) + if err != nil || u.Host == "" { + return endpoint + } + return u.Host +} + +// readProjectEndpoint pulls the AZURE_AI_PROJECT_ENDPOINT value out +// of the upstream `local.project-endpoint-set` check's Details map. +// Returns "" if not present or not a non-empty string — the caller +// is responsible for deciding whether that is a Skip or a hard fail. +func readProjectEndpoint(prior []Result) string { + for _, p := range prior { + if p.ID != "local.project-endpoint-set" { + continue + } + v, ok := p.Details["projectEndpoint"].(string) + if !ok { + return "" + } + return strings.TrimSpace(v) + } + return "" +} + +// foundryDetails builds the standard Details map for any Result the +// foundry-endpoint check emits. Centralizing this means a single +// place owns what is and isn't safe to surface in non-interactive +// mode (today: nothing here is secret; we never include the access +// token or the response body). +func foundryDetails(endpoint string, res foundryProbeResult) map[string]any { + d := map[string]any{ + "endpoint": endpoint, + } + if res.requestedURL != "" { + d["requestedURL"] = res.requestedURL + } + if res.statusCode != 0 { + d["statusCode"] = res.statusCode + } + return d +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_foundry_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_foundry_endpoint_test.go new file mode 100644 index 00000000000..9113072c196 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_foundry_endpoint_test.go @@ -0,0 +1,678 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// foundryProbeStub builds a Dependencies whose probeFoundryEndpoint +// seam returns a fixed foundryProbeResult and an AgentAPIVersion of +// `2025-11-15-preview`. Centralised so every status-code test reads +// at the same level of abstraction. +func foundryProbeStub(res foundryProbeResult) Dependencies { + return Dependencies{ + AgentAPIVersion: "2025-11-15-preview", + probeFoundryEndpoint: func(_ context.Context, _ string) foundryProbeResult { + return res + }, + } +} + +// passingPriors returns the upstream prior results that the foundry- +// endpoint check requires to actually run: a Pass for both +// `local.project-endpoint-set` (with the endpoint string in Details) +// and `remote.auth`. +func passingPriors(endpoint string) []Result { + return []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + { + ID: "local.project-endpoint-set", + Status: StatusPass, + Details: map[string]any{ + "projectEndpoint": endpoint, + }, + }, + {ID: "remote.auth", Status: StatusPass}, + } +} + +// ---- Skip-cascade contract ---- + +func TestCheckFoundryEndpoint_SkipsWhenProjectEndpointSetFailed(t *testing.T) { + t.Parallel() + + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + err: errors.New("probe should not have been called"), + })) + prior := []Result{ + {ID: "local.project-endpoint-set", Status: StatusFail}, + {ID: "remote.auth", Status: StatusPass}, + } + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "AZURE_AI_PROJECT_ENDPOINT") + require.Contains(t, got.Message, "local.project-endpoint-set") +} + +func TestCheckFoundryEndpoint_SkipsWhenAuthFailed(t *testing.T) { + t.Parallel() + + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + err: errors.New("probe should not have been called"), + })) + prior := []Result{ + { + ID: "local.project-endpoint-set", + Status: StatusPass, + Details: map[string]any{ + "projectEndpoint": "https://acct.services.ai.azure.com/api/projects/proj", + }, + }, + {ID: "remote.auth", Status: StatusFail}, + } + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "auth probe") + require.Contains(t, got.Message, "remote.auth") +} + +func TestCheckFoundryEndpoint_SkipsWhenEndpointMissingFromDetails(t *testing.T) { + t.Parallel() + + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + err: errors.New("probe should not have been called"), + })) + prior := []Result{ + { + ID: "local.project-endpoint-set", + Status: StatusPass, + Details: map[string]any{}, // missing projectEndpoint key + }, + {ID: "remote.auth", Status: StatusPass}, + } + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status, + "defensive skip — must not guess endpoint values") + require.Contains(t, got.Message, "Details") +} + +func TestCheckFoundryEndpoint_SkipsWhenAPIVersionMissing(t *testing.T) { + t.Parallel() + + // No AgentAPIVersion populated on Dependencies — wiring bug. + check := newCheckFoundryEndpoint(Dependencies{ + probeFoundryEndpoint: func(_ context.Context, _ string) foundryProbeResult { + return foundryProbeResult{err: errors.New("probe should not have been called")} + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriors("https://x.services.ai.azure.com/api/projects/p")) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "agent API version") +} + +// ---- Status-code → Result mapping (the heart of the check) ---- + +func TestCheckFoundryEndpoint_PassesOn200(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + statusCode: http.StatusOK, + requestedURL: endpoint + "/agents?api-version=2025-11-15-preview&limit=1", + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, "endpoint reachable") + require.Contains(t, got.Message, "HTTP 200") + require.Equal(t, endpoint, got.Details["endpoint"]) + require.Equal(t, http.StatusOK, got.Details["statusCode"]) +} + +func TestCheckFoundryEndpoint_FailsOn401WithAzdAuthLogin(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + statusCode: http.StatusUnauthorized, + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "401") + require.Contains(t, got.Message, "token expired") + require.Contains(t, got.Suggestion, "azd auth login") + require.NotEmpty(t, got.Links) +} + +func TestCheckFoundryEndpoint_FailsOn403WithTenantOrRBAC(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + statusCode: http.StatusForbidden, + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "403") + require.Contains(t, got.Message, "wrong tenant") + require.Contains(t, got.Message, "insufficient RBAC") + require.Contains(t, got.Suggestion, "tenant") + require.Contains(t, got.Suggestion, "remote.rbac") + require.NotContains(t, got.Suggestion, "azd auth login", + "403 must NOT recommend `azd auth login` — that's the 401 path") + require.NotEmpty(t, got.Links, + "403 must carry a docs Link so users have somewhere to start "+ + "acting on the suggestion (mirrors the C11 convention)") +} + +func TestCheckFoundryEndpoint_FailsOn404WithProvisionFix(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + statusCode: http.StatusNotFound, + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "404") + require.Contains(t, got.Message, "endpoint is wrong") + require.Contains(t, got.Suggestion, "azd provision") + require.Contains(t, got.Suggestion, "azd env set AZURE_AI_PROJECT_ENDPOINT") +} + +func TestCheckFoundryEndpoint_FailsOnServerError(t *testing.T) { + t.Parallel() + + cases := []int{500, 502, 503, 504, 599} + for _, code := range cases { + t.Run(http.StatusText(code), func(t *testing.T) { + t.Parallel() + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + statusCode: code, + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "service-side error") + require.Contains(t, got.Suggestion, "Retry") + }) + } +} + +func TestCheckFoundryEndpoint_FailsOnUnexpectedStatus(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + statusCode: http.StatusTeapot, + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "unexpected HTTP 418") + require.Contains(t, got.Suggestion, "verbose") +} + +func TestCheckFoundryEndpoint_FailsOnTransportError(t *testing.T) { + t.Parallel() + + endpoint := "https://typo.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + err: errors.New("dial tcp: lookup typo.services.ai.azure.com: no such host"), + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "could not reach") + require.Contains(t, got.Message, "typo.services.ai.azure.com") + require.Contains(t, got.Suggestion, "AZURE_AI_PROJECT_ENDPOINT") +} + +func TestCheckFoundryEndpoint_StripsMultiLineTransportError(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + multi := "primary cause\nstack frame 1\nstack frame 2" + check := newCheckFoundryEndpoint(foundryProbeStub(foundryProbeResult{ + err: errors.New(multi), + })) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Contains(t, got.Message, "primary cause") + require.NotContains(t, got.Message, "stack frame") +} + +// ---- Cancellation / timeout ---- + +func TestCheckFoundryEndpoint_SkipsOnUserCancellation(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(Dependencies{ + AgentAPIVersion: "2025-11-15-preview", + probeFoundryEndpoint: func(ctx context.Context, _ string) foundryProbeResult { + <-ctx.Done() + return foundryProbeResult{err: ctx.Err()} + }, + }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + got := check.Fn(ctx, Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "cancelled") +} + +func TestCheckFoundryEndpoint_FailsOnProbeTimeout(t *testing.T) { + t.Parallel() + + endpoint := "https://acct.services.ai.azure.com/api/projects/proj" + check := newCheckFoundryEndpoint(Dependencies{ + AgentAPIVersion: "2025-11-15-preview", + probeFoundryEndpoint: func(_ context.Context, _ string) foundryProbeResult { + return foundryProbeResult{err: context.DeadlineExceeded} + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "did not respond within") + require.Contains(t, got.Suggestion, "retry") +} + +// ---- Default probe fall-through ---- + +func TestCheckFoundryEndpoint_FallsBackToRealProbeWhenSeamMissing(t *testing.T) { + t.Parallel() + + // With probeFoundryEndpoint nil the check must call + // makeRealProbeFoundryEndpoint. We feed an already-cancelled ctx + // so the underlying http.DefaultClient call returns immediately + // regardless of the host's network state, and assert the + // cancellation classification kicks in. + check := newCheckFoundryEndpoint(Dependencies{ + AgentAPIVersion: "2025-11-15-preview", + }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + got := check.Fn(ctx, Options{}, passingPriors("https://x.services.ai.azure.com/api/projects/p")) + + // Either Skip (cancellation classified) or Fail (timeout / token + // acquisition error) is acceptable — we only need to prove the + // fall-through doesn't panic on a nil probe and produces a + // classified Result. + require.Contains(t, []Status{StatusSkip, StatusFail}, got.Status) +} + +// ---- End-to-end via a real httptest server (verifies the +// production probe assembly produces the expected HTTP request +// against a real net/http stack) ---- + +func TestRealProbeFoundryEndpoint_RequestShapeAgainstHTTPTestServer(t *testing.T) { + t.Parallel() + + // We can't easily inject a stub credential into + // makeRealProbeFoundryEndpoint without expanding its surface, so + // we exercise the *URL builder* end-to-end against an httptest + // TLS server (the production probe contract requires HTTPS, so a + // plaintext httptest server would be rejected by our own URL + // validation). This catches regressions where the builder + // produces a URL that fails to land on `/agents` once Go's + // net/http library has had a chance to canonicalize / send it — + // a class of failure that pure string assertions miss. + var seenPath, seenQuery string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + got, err := buildFoundryProbeURL(srv.URL, "2025-11-15-preview") + require.NoError(t, err) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, got, nil) + require.NoError(t, err) + // Use the test server's pre-configured client (trusts the + // httptest CA) instead of http.DefaultClient. + resp, err := srv.Client().Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + + require.Equal(t, "/agents", seenPath, + "the built URL must resolve to /agents on the wire") + require.Contains(t, seenQuery, "api-version=2025-11-15-preview") + require.Contains(t, seenQuery, "limit=1", + "the probe must use limit=1 (matches production "+ + "agent_api/operations.go) — not $top=1") +} + +// ---- buildFoundryProbeURL ---- + +func TestBuildFoundryProbeURL(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + endpoint string + wantContains []string // substrings that MUST appear + wantMissing []string // substrings that MUST NOT appear + }{ + { + name: "no trailing slash", + endpoint: "https://x.services.ai.azure.com/api/projects/proj", + wantContains: []string{ + "https://x.services.ai.azure.com/api/projects/proj/agents?", + "api-version=2025-11-15-preview", + "limit=1", + }, + }, + { + name: "trailing slash tolerated", + endpoint: "https://x.services.ai.azure.com/api/projects/proj/", + wantContains: []string{ + "https://x.services.ai.azure.com/api/projects/proj/agents?", + "limit=1", + }, + }, + { + name: "user-supplied junk query is overridden but path survives", + endpoint: "https://x.services.ai.azure.com/api/projects/proj?api-version=evil&injected=x", + wantContains: []string{ + "/api/projects/proj/agents?", + "api-version=2025-11-15-preview", + "limit=1", + }, + wantMissing: []string{"api-version=evil", "injected=x"}, + }, + { + name: "fragment stripped and path survives", + endpoint: "https://x.services.ai.azure.com/api/projects/proj#evil/agents", + wantContains: []string{ + "/api/projects/proj/agents?", + "api-version=2025-11-15-preview", + "limit=1", + }, + wantMissing: []string{"#"}, + }, + { + name: "whitespace trimmed", + endpoint: " https://x.services.ai.azure.com/api/projects/proj ", + wantContains: []string{ + "https://x.services.ai.azure.com/api/projects/proj/agents?", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := buildFoundryProbeURL(tc.endpoint, "2025-11-15-preview") + require.NoError(t, err) + for _, sub := range tc.wantContains { + require.Containsf(t, got, sub, "URL %q missing substring %q", got, sub) + } + for _, sub := range tc.wantMissing { + require.NotContainsf(t, got, sub, + "URL %q must not contain %q", got, sub) + } + // Universal positive assertion: every successful build + // must include the literal /agents path segment with + // the canonical query separator immediately after. This + // is the regression test for the path-loss bug uncovered + // by reviewers: a builder that silently dropped /agents + // (via ?- or #-collision) would have passed every + // per-case `wantContains` list above before this line + // existed. + require.Contains(t, got, "/agents?", + "every built URL must include `/agents?` — never let "+ + "a user-supplied query/fragment displace the path") + }) + } +} + +func TestBuildFoundryProbeURL_RejectsNonHTTPSOrMalformedEndpoint(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + endpoint string + }{ + {name: "http scheme", endpoint: "http://x.services.ai.azure.com/api/projects/proj"}, + {name: "no scheme", endpoint: "x.services.ai.azure.com/api/projects/proj"}, + {name: "opaque string", endpoint: "not a url"}, + {name: "empty", endpoint: ""}, + {name: "relative path", endpoint: "/api/projects/proj"}, + {name: "ftp scheme", endpoint: "ftp://x.services.ai.azure.com/api/projects/proj"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := buildFoundryProbeURL(tc.endpoint, "2025-11-15-preview") + require.Error(t, err, + "builder must reject non-HTTPS / relative / malformed "+ + "endpoints so the probe never sends a bearer token "+ + "to the wrong scheme or host") + }) + } +} + +func TestValidateFoundryEndpoint(t *testing.T) { + t.Parallel() + + t.Run("accepts well-formed https", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateFoundryEndpoint( + "https://x.services.ai.azure.com/api/projects/proj")) + }) + + t.Run("accepts https with trailing slash", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateFoundryEndpoint( + "https://x.services.ai.azure.com/api/projects/proj/")) + }) + + t.Run("rejects http", func(t *testing.T) { + t.Parallel() + err := validateFoundryEndpoint( + "http://x.services.ai.azure.com/api/projects/proj") + require.Error(t, err) + require.Contains(t, err.Error(), "https") + }) + + t.Run("rejects empty", func(t *testing.T) { + t.Parallel() + err := validateFoundryEndpoint(" ") + require.Error(t, err) + require.Contains(t, err.Error(), "empty") + }) + + t.Run("rejects relative URL", func(t *testing.T) { + t.Parallel() + err := validateFoundryEndpoint("/api/projects/proj") + require.Error(t, err) + }) + + t.Run("rejects opaque non-URL string", func(t *testing.T) { + t.Parallel() + err := validateFoundryEndpoint("not a url") + require.Error(t, err) + }) +} + +func TestCheckFoundryEndpoint_FailsOnNonHTTPSEndpoint(t *testing.T) { + t.Parallel() + + // Stub probe that would panic if invoked — the check must fail + // the request at validation time, BEFORE any token is acquired + // or any probe is dispatched. + check := newCheckFoundryEndpoint(Dependencies{ + AgentAPIVersion: "2025-11-15-preview", + probeFoundryEndpoint: func(_ context.Context, _ string) foundryProbeResult { + t.Fatal("probe must not be invoked for a non-HTTPS endpoint") + return foundryProbeResult{} + }, + }) + + endpoint := "http://x.services.ai.azure.com/api/projects/proj" + got := check.Fn(t.Context(), Options{}, passingPriors(endpoint)) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "invalid") + require.Contains(t, got.Suggestion, "azd env set AZURE_AI_PROJECT_ENDPOINT") + require.Equal(t, endpoint, got.Details["endpoint"]) + require.NotEmpty(t, got.Details["validationError"]) +} + +func TestCheckFoundryEndpoint_FailsOnMalformedEndpoint(t *testing.T) { + t.Parallel() + + check := newCheckFoundryEndpoint(Dependencies{ + AgentAPIVersion: "2025-11-15-preview", + probeFoundryEndpoint: func(_ context.Context, _ string) foundryProbeResult { + t.Fatal("probe must not be invoked for a malformed endpoint") + return foundryProbeResult{} + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriors("not a url")) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "invalid") +} + +// ---- endpointHost / readProjectEndpoint helpers ---- + +func TestEndpointHost(t *testing.T) { + t.Parallel() + + require.Equal(t, "x.services.ai.azure.com", + endpointHost("https://x.services.ai.azure.com/api/projects/proj")) + require.Equal(t, "not a url", + endpointHost("not a url"), + "on parse failure / empty host, the input is returned verbatim") + require.Equal(t, "", + endpointHost("")) +} + +func TestReadProjectEndpoint(t *testing.T) { + t.Parallel() + + t.Run("returns trimmed value from Details", func(t *testing.T) { + t.Parallel() + got := readProjectEndpoint([]Result{ + { + ID: "local.project-endpoint-set", + Details: map[string]any{"projectEndpoint": " https://x.services.ai.azure.com "}, + }, + }) + require.Equal(t, "https://x.services.ai.azure.com", got) + }) + + t.Run("returns empty when result missing", func(t *testing.T) { + t.Parallel() + got := readProjectEndpoint([]Result{ + {ID: "remote.auth"}, + }) + require.Empty(t, got) + }) + + t.Run("returns empty when value not a string", func(t *testing.T) { + t.Parallel() + got := readProjectEndpoint([]Result{ + { + ID: "local.project-endpoint-set", + Details: map[string]any{"projectEndpoint": 12345}, + }, + }) + require.Empty(t, got) + }) + + t.Run("returns empty when Details nil", func(t *testing.T) { + t.Parallel() + got := readProjectEndpoint([]Result{ + {ID: "local.project-endpoint-set"}, + }) + require.Empty(t, got) + }) +} + +// ---- foundryDetails ---- + +func TestFoundryDetails_OmitsZeroStatusAndEmptyURL(t *testing.T) { + t.Parallel() + + d := foundryDetails("https://x", foundryProbeResult{}) + require.Equal(t, "https://x", d["endpoint"]) + _, hasStatus := d["statusCode"] + require.False(t, hasStatus, "zero statusCode should not appear in Details") + _, hasURL := d["requestedURL"] + require.False(t, hasURL, "empty requestedURL should not appear in Details") +} + +func TestFoundryDetails_IncludesStatusAndURLWhenSet(t *testing.T) { + t.Parallel() + + d := foundryDetails("https://x", foundryProbeResult{ + statusCode: 200, + requestedURL: "https://x/agents?api-version=2025-11-15-preview&limit=1", + }) + require.Equal(t, 200, d["statusCode"]) + require.Contains(t, d["requestedURL"], "/agents") +} + +// ---- Sanity check: token must not leak via Details ---- + +func TestFoundryDetails_NeverContainsToken(t *testing.T) { + t.Parallel() + + // The probe surface is intentionally narrow — there is no token + // field on foundryProbeResult. This test pins that contract: if + // someone adds one in the future, foundryDetails must still + // refuse to surface it. + d := foundryDetails("https://x", foundryProbeResult{ + statusCode: 200, + requestedURL: "https://x/agents?api-version=2025-11-15-preview", + }) + for k, v := range d { + require.NotContains(t, strings.ToLower(k), "token", + "Details key %q looks token-related", k) + if s, ok := v.(string); ok { + require.NotContains(t, strings.ToLower(s), "bearer ", + "Details value contains what looks like a bearer token") + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_local.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_local.go new file mode 100644 index 00000000000..adbef1883b2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_local.go @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "fmt" + "strconv" + "strings" + + "azureaiagent/internal/cmd/nextstep" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// MinNewBackendVersion is the floor extension version required to talk to +// the new hosted-agents backend. Extensions below this floor can still +// drive the legacy ACA backend; the floor is advisory, surfaced as a +// Warning rather than a hard Fail. The constant lives next to its sole +// consumer (Check `local.grpc-extension`) so that bumping it is a +// one-line change with no scattered references. +// +// Source: hosted-agents quickstart docs at +// https://learn.microsoft.com/azure/foundry/agents/quickstarts/quickstart-hosted-agent +const MinNewBackendVersion = "0.1.27-preview" + +// Dependencies bundles the runtime services local checks consume. The +// Cobra wiring in the parent internal/cmd package constructs this from +// `azdext.NewAzdClient()` and the extension's compiled-in version +// constant; tests inject directly. +// +// AzdClient may be nil if NewAzdClient failed at startup (e.g. when the +// extension is launched outside `azd ext run`). AzdClientErr captures +// the cause so Check `local.grpc-extension` can surface it verbatim. +// Downstream checks that need the client must Skip cleanly rather than +// Fail — a cascade of identical "no client" failures is noise. +type Dependencies struct { + AzdClient *azdext.AzdClient + AzdClientErr error + ExtensionVersion string + + // AgentAPIVersion is the Foundry Agents api-version the remote + // probes target. The doctor command's Cobra wiring populates this + // with the package-level DefaultAgentAPIVersion constant so the + // design's "single source of truth" requirement is honored — both + // the runtime invoke flow (init, invoke, listen, monitor, + // session, show) and the doctor probe pin against the same + // constant. Tests can override per-call to assert URL composition + // without coupling to the production value. + AgentAPIVersion string + + // assembleState is a test seam: when non-nil it replaces the + // production `nextstep.AssembleState` call inside the + // `local.manual-env-vars` check, letting unit tests inject a + // pre-computed State without standing up a temp project on disk. + // Lowercase so external packages cannot reach it. Production code + // (NewLocalChecks via the Cobra wiring) leaves it nil. + assembleState func(ctx context.Context, client *azdext.AzdClient) (*nextstep.State, []error) + + // probeAuth is a test seam: when non-nil it replaces the + // production `realProbeAuth` call inside the `remote.auth` check, + // letting unit tests inject controlled token-acquisition outcomes + // (error, expired, near-expiry, pass-with-UPN, pass-without-UPN) + // without invoking `azd auth token`. Lowercase so external + // packages cannot reach it; production wiring leaves it nil. + probeAuth func(ctx context.Context) authProbeResult + + // probeFoundryEndpoint is a test seam: when non-nil it replaces + // the production `realProbeFoundryEndpoint` call inside the + // `remote.foundry-endpoint` check, letting unit tests assert each + // HTTP-status branch (200/401/403/404/5xx/network) without + // standing up a live Foundry service. The probe receives the + // `AZURE_AI_PROJECT_ENDPOINT` value resolved by the upstream + // `local.project-endpoint-set` check; production wiring leaves + // this field nil. + probeFoundryEndpoint func(ctx context.Context, endpoint string) foundryProbeResult + + // probeDeveloperRBAC is a test seam: when non-nil it replaces + // the production `project.QueryDeveloperRBAC` call inside the + // `remote.rbac` check, letting unit tests cover the Pass / Fail / + // transient-error branches without spinning up Graph + ARM + // fakes. The signature mirrors `project.QueryDeveloperRBAC` + // exactly so the wiring inside `newCheckRBAC` is a single + // `if probe == nil { probe = project.QueryDeveloperRBAC }` + // substitution. + probeDeveloperRBAC func( + ctx context.Context, + azdClient *azdext.AzdClient, + projectResourceID string, + ) (*project.DeveloperRBACResult, error) + + // readProjectResourceIDFn is a test seam: when non-nil it + // replaces the production `readProjectResourceID` call inside + // the `remote.rbac` check, letting unit tests exercise the + // downstream probe-injection paths (Pass / Fail / cascade) + // without instantiating a real gRPC AzdClient just to read one + // env var. Production wiring leaves this nil. + readProjectResourceIDFn func( + ctx context.Context, + azdClient *azdext.AzdClient, + ) (string, error) + + // probeAgentStatus is a test seam: when non-nil it replaces + // the production `realProbeAgentStatus` call inside the + // `remote.agent-status` check, letting unit tests cover the + // Active / Creating / Failed / NotFound / transport branches + // without standing up a live Foundry agent version. The probe + // is invoked once per service (so a single unit test can drive + // a multi-service aggregate by returning different statuses + // for different (name, version) pairs). Production wiring + // leaves this nil. + probeAgentStatus func( + ctx context.Context, + endpoint, agentName, agentVersion string, + ) agentStatusProbeResult + + // readAgentNameVersionFn is a test seam: when non-nil it + // replaces the production `readAgentNameVersion` call inside + // the `remote.agent-status` check. It returns the deployed + // agent name + version for a given service from the active + // azd environment. Wiring through a seam avoids the need to + // stand up a real gRPC AzdClient for unit tests that just + // need to assert classification logic. Production wiring + // leaves this nil. + readAgentNameVersionFn func( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + ) (name string, version string, err error) + + // probeAgentPrincipal is a test seam for the + // `remote.agent-identity-roles` check (Phase 5 C12). It returns + // the agent's managed-identity principal ID by calling + // GetAgentVersion and reading `instance_identity.principal_id`. + // Production wiring leaves this nil; the check substitutes + // `makeRealProbeAgentPrincipal(deps.AgentAPIVersion)` when nil. + probeAgentPrincipal func( + ctx context.Context, + endpoint, agentName, agentVersion string, + ) agentIdentityProbeResult + + // queryAgentIdentityRoles is a test seam for the + // `remote.agent-identity-roles` check (Phase 5 C12). When + // non-nil it replaces the production + // `project.QueryAgentIdentityRoles` call inside the check, + // letting unit tests exercise per-agent classification + // (fine / underscoped / empty / unknown) and aggregate folding + // without instantiating real ARM clients. Signature mirrors + // `project.QueryAgentIdentityRoles` exactly so the wiring is a + // single `if query == nil { query = project.QueryAgentIdentityRoles }` + // substitution. Production wiring leaves this nil. + queryAgentIdentityRoles func( + ctx context.Context, + azdClient *azdext.AzdClient, + projectResourceID string, + principals []project.AgentPrincipal, + ) (*project.AgentIdentityRolesResult, error) + + // probeModelDeployments is a test seam for the + // `remote.model-deployments` check (Phase 5 C13). When non-nil it + // replaces the production `realProbeModelDeployments` call inside + // the check, letting unit tests cover the all-match / partial / + // none / transport-error branches without spinning up an ARM + // `DeploymentsClient`. The probe receives a per-account scope + // (subscription, resourceGroup, accountName) and returns the + // deployment names that exist under that account. Production + // wiring leaves this nil. + probeModelDeployments func( + ctx context.Context, + subscriptionID, resourceGroup, accountName string, + ) ([]string, error) + + // lookupToolboxEnv is a test seam for the `local.toolboxes` + // check (Phase 5 C14). When non-nil it replaces the production + // `makeRealToolboxEnvLookup` closure inside the check, letting + // unit tests cover the all-present / partial / none / + // transport-error branches by returning canned `(value, err)` + // tuples per env key. Production wiring leaves this nil and the + // check binds `client.Environment().GetValue` on first call. + lookupToolboxEnv func(ctx context.Context, key string) (value string, err error) + + // probeFoundryConnections is a test seam for the + // `remote.connections` check (Phase 5 C15). When non-nil it + // replaces the production `realProbeFoundryConnections` call + // inside the check, letting unit tests cover the all-match / + // partial / none / probe-error branches without going through + // `azd auth` or hitting Foundry. The probe receives the account + // + project derived from `AZURE_AI_PROJECT_ID` and returns the + // connection names that exist on that project. + probeFoundryConnections func( + ctx context.Context, + accountName, projectName string, + ) ([]string, error) +} + +// NewLocalChecks returns the canonical sequence of local doctor checks +// in execution order. Phase 4.2 covered checks 1-3; Phase 4.3 added +// checks 4-6 (agent service detected, project endpoint set, agent.yaml +// valid). Phase 5 C9 appends check 7 (manual env vars set). Phase 5 +// C14 appends check 8 (`local.toolboxes`) which reads per-toolbox MCP +// endpoint env vars; it is local because it does not call ARM / +// Foundry (only the active azd environment). +func NewLocalChecks(deps Dependencies) []Check { + return []Check{ + newCheckGRPCAndVersion(deps), + newCheckProjectConfig(deps), + newCheckEnvironmentSelected(deps), + newCheckAgentServiceDetected(deps), + newCheckProjectEndpointSet(deps), + newCheckAgentYAMLValid(deps), + newCheckManualEnvVars(deps), + newCheckToolboxes(deps), + } +} + +// newCheckGRPCAndVersion produces Check `local.grpc-extension`. It +// verifies the gRPC channel back to azd is available (NewAzdClient +// returned a non-nil client) and that the extension is at or above the +// new-hosted-agents backend floor. Below the floor the check Warns — +// the legacy ACA backend continues to work and the user does not need +// to upgrade immediately. +// +// Dev builds (Version == "dev" or empty) skip the floor check: there is +// no reliable comparison and a Warning on every developer iteration is +// noise. +func newCheckGRPCAndVersion(deps Dependencies) Check { + return Check{ + ID: "local.grpc-extension", + Name: "azd extension reachable", + Fn: func(_ context.Context, _ Options, _ []Result) Result { + if deps.AzdClient == nil { + msg := "gRPC channel to azd is unavailable" + if deps.AzdClientErr != nil { + msg = fmt.Sprintf("gRPC channel to azd unavailable: %v", deps.AzdClientErr) + } + return Result{ + Status: StatusFail, + Message: msg, + Suggestion: "Run the extension via `azd ai agent doctor` rather than launching the extension binary directly.", + } + } + + ver := strings.TrimSpace(deps.ExtensionVersion) + if ver == "" || ver == "dev" { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("azd extension reachable (version: %s).", coalesce(ver, "unknown")), + } + } + + // If the version string is non-empty/non-"dev" but still can't be parsed + // (e.g. a build label like "canary" or an unexpected future format), + // surface Pass but mark the floor check as skipped rather than silently + // claiming the floor was verified. + if _, ok := parseMainVersion(ver); !ok { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "azd extension reachable (version %s; floor check skipped: version string not parseable).", + ver), + Details: map[string]any{ + "extensionVersion": ver, + "floorChecked": false, + }, + } + } + + if compareVersions(ver, MinNewBackendVersion) < 0 { + return Result{ + Status: StatusWarn, + Message: fmt.Sprintf( + "Extension version %s is older than %s; the new hosted-agents backend requires the floor.", + ver, MinNewBackendVersion), + Suggestion: "Upgrade with `azd ext upgrade azure.ai.agents`.", + Links: []string{"https://aka.ms/hostedagents/tsg/readme"}, + Details: map[string]any{ + "extensionVersion": ver, + "minBackendVersion": MinNewBackendVersion, + }, + } + } + + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("azd extension reachable (version %s).", ver), + } + }, + } +} + +// newCheckProjectConfig produces Check `local.azure-yaml`. It probes the +// azd Project service for the resolved project config. The check Fails +// when the call returns an error OR the response carries a nil Project +// (azd's convention for "no azure.yaml in the working directory"). The +// suggestion mirrors the wording used in helpers.go's resolveConfigPath +// so users see consistent guidance across commands. +// +// Skips cleanly when the gRPC client is unavailable — Check +// `local.grpc-extension` will already have failed and produced the +// actionable error. +func newCheckProjectConfig(deps Dependencies) Check { + return Check{ + ID: "local.azure-yaml", + Name: "azure.yaml present and parseable", + Fn: func(ctx context.Context, _ Options, _ []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable", + } + } + + resp, err := deps.AzdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + suggestion := "Run from a directory containing `azure.yaml`, or initialize one with `azd init`." + if isTransportFailure(err) { + // `azdext.NewAzdClient` constructs a lazy gRPC channel, so the + // nil-client check above cannot detect a stale/unreachable + // `AZD_SERVER` endpoint. The transport failure surfaces here on + // the first RPC — swap the suggestion so the user looks at the + // channel, not at `azure.yaml`. + suggestion = "Re-run via `azd ai agent doctor`; the extension cannot reach azd's gRPC channel." + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to get project config: %v", err), + Suggestion: suggestion, + } + } + if resp == nil || resp.Project == nil { + return Result{ + Status: StatusFail, + Message: "failed to get project config (is there an azure.yaml?)", + Suggestion: "Run from a directory containing `azure.yaml`, or initialize one with `azd init`.", + } + } + + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("azure.yaml parsed (project: %s).", resp.Project.Name), + Details: map[string]any{ + "projectPath": resp.Project.Path, + "projectName": resp.Project.Name, + }, + } + }, + } +} + +// newCheckEnvironmentSelected produces Check +// `local.environment-selected`. It probes the azd Environment service +// for the currently-selected environment. The check Fails when the call +// errors, or when the response carries a nil Environment / empty Name. +// +// Skips cleanly when the gRPC client is unavailable OR when the +// `local.azure-yaml` check failed — environment selection is meaningless +// without a project to anchor it. +func newCheckEnvironmentSelected(deps Dependencies) Check { + return Check{ + ID: "local.environment-selected", + Name: "azd environment selected", + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable", + } + } + for _, p := range prior { + if p.ID == "local.azure-yaml" && p.Status == StatusFail { + return Result{ + Status: StatusSkip, + Message: "skipped: azure.yaml check failed", + } + } + } + + resp, err := deps.AzdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + suggestion := "Create one with `azd env new ` or select an existing one with `azd env select `." + if isTransportFailure(err) { + suggestion = "Re-run via `azd ai agent doctor`; the extension cannot reach azd's gRPC channel." + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to get current environment: %v", err), + Suggestion: suggestion, + } + } + if resp == nil || resp.Environment == nil || resp.Environment.Name == "" { + return Result{ + Status: StatusFail, + Message: "no azd environment is selected", + Suggestion: "Create one with `azd env new ` or select an existing one with `azd env select `.", + } + } + + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("environment selected: %s.", resp.Environment.Name), + Details: map[string]any{ + "environmentName": resp.Environment.Name, + }, + } + }, + } +} + +// isTransportFailure reports whether err is a gRPC transport-class failure +// (channel unreachable, deadline exceeded) as opposed to a server-side +// application error. Used by downstream checks to swap the user-facing +// suggestion when an RPC fails because the channel itself is broken, +// rather than because the project/environment is misconfigured. +func isTransportFailure(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return false + } + switch st.Code() { + case codes.Unavailable, codes.DeadlineExceeded: + return true + } + return false +} + +// coalesce returns the first non-empty string in values, or "" if all +// are empty. Used to keep the version-floor check's Pass message +// readable when the version string is blank. +func coalesce(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +// compareVersions compares two version strings numerically on the first +// three dotted components, ignoring any "-suffix" pre-release or "+build" +// metadata. A leading "v" is tolerated. Returns -1 if ab. +// +// The fail-open behavior on invalid input is deliberate: a malformed +// version string should never trigger a Warning suggesting the user +// "upgrade" — a noisy Warn for a real bug is worse than a missed Warn for +// a malformed string. Callers that need strict comparison should use a +// real semver library; for the doctor's floor check, three-component +// numeric comparison is sufficient (the pre-release suffix `-preview` is +// shared between extension and floor and therefore lexicographically +// equal — irrelevant to the cmp). +func compareVersions(a, b string) int { + pa, oka := parseMainVersion(a) + pb, okb := parseMainVersion(b) + if !oka || !okb { + return 0 + } + for i := range 3 { + if pa[i] < pb[i] { + return -1 + } + if pa[i] > pb[i] { + return 1 + } + } + return 0 +} + +// parseMainVersion splits "v?X.Y.Z[-suffix][+build]" into [X, Y, Z] as +// non-negative integers. Returns (zero, false) on any parse error. +func parseMainVersion(v string) ([3]int, bool) { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + parts := strings.SplitN(v, ".", 3) + if len(parts) != 3 { + return [3]int{}, false + } + var out [3]int + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return [3]int{}, false + } + out[i] = n + } + return out, true +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_local_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_local_test.go new file mode 100644 index 00000000000..0dd33f71518 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_local_test.go @@ -0,0 +1,562 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ---- fake gRPC servers (Project + Environment) ---- + +type fakeProjectServer struct { + azdext.UnimplementedProjectServiceServer + resp *azdext.GetProjectResponse + err error +} + +func (s *fakeProjectServer) Get( + context.Context, *azdext.EmptyRequest, +) (*azdext.GetProjectResponse, error) { + if s.err != nil { + return nil, s.err + } + return s.resp, nil +} + +type fakeEnvironmentServer struct { + azdext.UnimplementedEnvironmentServiceServer + resp *azdext.EnvironmentResponse + err error + + // GetValue stub fields (Phase 4.3). + valueResp *azdext.KeyValueResponse + valueErr error +} + +func (s *fakeEnvironmentServer) GetCurrent( + context.Context, *azdext.EmptyRequest, +) (*azdext.EnvironmentResponse, error) { + if s.err != nil { + return nil, s.err + } + return s.resp, nil +} + +func (s *fakeEnvironmentServer) GetValue( + context.Context, *azdext.GetEnvRequest, +) (*azdext.KeyValueResponse, error) { + if s.valueErr != nil { + return nil, s.valueErr + } + return s.valueResp, nil +} + +// newTestAzdClient spins up an in-process gRPC server with the supplied +// Project + Environment server stubs and returns a client wired to its +// address. The server, listener, and client are all torn down via +// t.Cleanup. Pattern mirrors `init_foundry_resources_helpers_test.go`'s +// `newTestAzdClient` — kept local to the doctor package so the doctor +// has no cross-package test-only imports. +func newTestAzdClient( + t *testing.T, + projectServer *fakeProjectServer, + envServer *fakeEnvironmentServer, +) *azdext.AzdClient { + t.Helper() + + grpcServer := grpc.NewServer() + azdext.RegisterProjectServiceServer(grpcServer, projectServer) + azdext.RegisterEnvironmentServiceServer(grpcServer, envServer) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + serveErr := make(chan error, 1) + go func() { + if err := grpcServer.Serve(listener); err != nil { + serveErr <- err + } + }() + + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + select { + case err := <-serveErr: + require.ErrorIs(t, err, grpc.ErrServerStopped) + default: + } + }) + + azdClient, err := azdext.NewAzdClient(azdext.WithAddress(listener.Addr().String())) + require.NoError(t, err) + t.Cleanup(func() { azdClient.Close() }) + + return azdClient +} + +// ---- Check `local.grpc-extension` ---- + +func TestCheckGRPCAndVersion_NoClient_Fails(t *testing.T) { + t.Parallel() + + check := newCheckGRPCAndVersion(Dependencies{ + AzdClient: nil, + AzdClientErr: errors.New("dial tcp 127.0.0.1:0: connect: connection refused"), + }) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "gRPC channel to azd unavailable") + require.Contains(t, got.Message, "connection refused") + require.NotEmpty(t, got.Suggestion) + // Suggestion should be version-agnostic — the extension declares its + // own required azd floor in extension.yaml; doctor must not duplicate it. + require.NotContains(t, got.Suggestion, "1.24.0") +} + +func TestCheckGRPCAndVersion_NoClient_NilErr_StillFails(t *testing.T) { + t.Parallel() + + check := newCheckGRPCAndVersion(Dependencies{AzdClient: nil, AzdClientErr: nil}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Equal(t, "gRPC channel to azd is unavailable", got.Message) +} + +func TestCheckGRPCAndVersion_DevBuild_Passes(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + + for _, ver := range []string{"", "dev", " "} { + check := newCheckGRPCAndVersion(Dependencies{ + AzdClient: client, + ExtensionVersion: ver, + }) + got := check.Fn(t.Context(), Options{}, nil) + require.Equal(t, StatusPass, got.Status, "ver=%q", ver) + require.Empty(t, got.Suggestion, "dev/empty builds should not nag") + } +} + +func TestCheckGRPCAndVersion_BelowFloor_Warns(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + + check := newCheckGRPCAndVersion(Dependencies{ + AzdClient: client, + ExtensionVersion: "0.1.26-preview", + }) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusWarn, got.Status) + require.Contains(t, got.Message, "0.1.26-preview") + require.Contains(t, got.Message, MinNewBackendVersion) + require.Contains(t, got.Suggestion, "azd ext upgrade azure.ai.agents") + require.Contains(t, got.Links, "https://aka.ms/hostedagents/tsg/readme") + require.Equal(t, "0.1.26-preview", got.Details["extensionVersion"]) + require.Equal(t, MinNewBackendVersion, got.Details["minBackendVersion"]) +} + +func TestCheckGRPCAndVersion_EqualFloor_Passes(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + + check := newCheckGRPCAndVersion(Dependencies{ + AzdClient: client, + ExtensionVersion: MinNewBackendVersion, + }) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Empty(t, got.Suggestion) +} + +func TestCheckGRPCAndVersion_AboveFloor_Passes(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + + check := newCheckGRPCAndVersion(Dependencies{ + AzdClient: client, + ExtensionVersion: "0.2.0", + }) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) +} + +// TestCheckGRPCAndVersion_UnparseableVersion_PassesButFlagsFloorSkipped +// pins the contract for non-empty/non-"dev" version strings that fail to +// parse (e.g. "canary", "preview-beta-1"). The check must still Pass — the +// gRPC channel is healthy — but the message must distinguish "above floor" +// from "couldn't verify floor", and Details["floorChecked"] must be false +// so downstream consumers (e.g. JSON output) can tell the difference. +func TestCheckGRPCAndVersion_UnparseableVersion_PassesButFlagsFloorSkipped(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + + for _, ver := range []string{"canary", "preview-beta-1", "1.2"} { + check := newCheckGRPCAndVersion(Dependencies{ + AzdClient: client, + ExtensionVersion: ver, + }) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equalf(t, StatusPass, got.Status, "ver=%q", ver) + require.Containsf(t, got.Message, ver, "ver=%q: message should echo the version", ver) + require.Containsf(t, got.Message, "floor check skipped", "ver=%q", ver) + require.NotContainsf(t, got.Message, "older than", "ver=%q must not claim below-floor", ver) + require.Emptyf(t, got.Suggestion, "ver=%q: unparseable version should not nag", ver) + require.Equalf(t, false, got.Details["floorChecked"], "ver=%q", ver) + require.Equalf(t, ver, got.Details["extensionVersion"], "ver=%q", ver) + } +} + +// ---- Check `local.azure-yaml` ---- + +func TestCheckProjectConfig_NoClient_Skips(t *testing.T) { + t.Parallel() + + check := newCheckProjectConfig(Dependencies{AzdClient: nil}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azd extension not reachable") +} + +func TestCheckProjectConfig_GrpcError_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{err: status.Error(codes.NotFound, "no project")}, + &fakeEnvironmentServer{}, + ) + check := newCheckProjectConfig(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to get project config") + require.Contains(t, got.Suggestion, "azd init") +} + +func TestCheckProjectConfig_NilProject_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{Project: nil}}, + &fakeEnvironmentServer{}, + ) + check := newCheckProjectConfig(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "is there an azure.yaml?") + require.Contains(t, got.Suggestion, "azd init") +} + +func TestCheckProjectConfig_Pass(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{ + resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{Name: "my-agent", Path: "/abs/path"}, + }, + }, + &fakeEnvironmentServer{}, + ) + check := newCheckProjectConfig(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, "my-agent") + require.Equal(t, "/abs/path", got.Details["projectPath"]) + require.Equal(t, "my-agent", got.Details["projectName"]) +} + +// TestCheckProjectConfig_TransportError_SwapsSuggestion locks the +// transport-aware suggestion swap. `azdext.NewAzdClient` constructs the +// gRPC channel lazily, so a non-nil client can still fail on the first +// RPC if AZD_SERVER is stale or unreachable. When the resulting error +// carries a transport-class gRPC code, the suggestion must point the +// user at the channel rather than at `azure.yaml`. +func TestCheckProjectConfig_TransportError_SwapsSuggestion(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + code codes.Code + }{ + {"Unavailable", codes.Unavailable}, + {"DeadlineExceeded", codes.DeadlineExceeded}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client := newTestAzdClient(t, + &fakeProjectServer{err: status.Error(tc.code, "transport boom")}, + &fakeEnvironmentServer{}, + ) + check := newCheckProjectConfig(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to get project config") + require.Contains(t, got.Suggestion, "azd ai agent doctor") + require.Contains(t, got.Suggestion, "gRPC channel") + // And explicitly *not* the misleading "azd init" path. + require.NotContains(t, got.Suggestion, "azd init") + }) + } +} + +// ---- Check `local.environment-selected` ---- + +func TestCheckEnvironmentSelected_NoClient_Skips(t *testing.T) { + t.Parallel() + + check := newCheckEnvironmentSelected(Dependencies{AzdClient: nil}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azd extension not reachable") +} + +func TestCheckEnvironmentSelected_SkipsWhenProjectCheckFailed(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{ + resp: &azdext.EnvironmentResponse{Environment: &azdext.Environment{Name: "dev"}}, + }, + ) + check := newCheckEnvironmentSelected(Dependencies{AzdClient: client}) + prior := []Result{{ID: "local.azure-yaml", Status: StatusFail}} + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azure.yaml check failed") +} + +func TestCheckEnvironmentSelected_GrpcError_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{err: status.Error(codes.Internal, "boom")}, + ) + check := newCheckEnvironmentSelected(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to get current environment") + require.Contains(t, got.Suggestion, "azd env new") + require.Contains(t, got.Suggestion, "azd env select") +} + +func TestCheckEnvironmentSelected_EmptyName_Fails(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + resp *azdext.EnvironmentResponse + }{ + {"nil response wrapper", nil}, + {"nil Environment", &azdext.EnvironmentResponse{Environment: nil}}, + {"empty Name", &azdext.EnvironmentResponse{Environment: &azdext.Environment{Name: ""}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{resp: tc.resp}, + ) + check := newCheckEnvironmentSelected(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Equal(t, "no azd environment is selected", got.Message) + }) + } +} + +func TestCheckEnvironmentSelected_Pass(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{ + resp: &azdext.EnvironmentResponse{Environment: &azdext.Environment{Name: "staging"}}, + }, + ) + check := newCheckEnvironmentSelected(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, "staging") + require.Equal(t, "staging", got.Details["environmentName"]) +} + +// TestCheckEnvironmentSelected_TransportError_SwapsSuggestion is the +// `local.environment-selected` sibling of the project-config transport +// test. Same rationale: a transport-class gRPC code means the channel is +// the root cause, not the absence of an environment. +func TestCheckEnvironmentSelected_TransportError_SwapsSuggestion(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{err: status.Error(codes.Unavailable, "transport boom")}, + ) + check := newCheckEnvironmentSelected(Dependencies{AzdClient: client}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to get current environment") + require.Contains(t, got.Suggestion, "azd ai agent doctor") + require.Contains(t, got.Suggestion, "gRPC channel") + require.NotContains(t, got.Suggestion, "azd env new") +} + +// ---- NewLocalChecks ordering / IDs ---- + +func TestNewLocalChecks_OrderAndIDs(t *testing.T) { + t.Parallel() + + checks := NewLocalChecks(Dependencies{}) + require.Len(t, checks, 8) + + want := []struct { + id string + name string + remote bool + }{ + {"local.grpc-extension", "azd extension reachable", false}, + {"local.azure-yaml", "azure.yaml present and parseable", false}, + {"local.environment-selected", "azd environment selected", false}, + {"local.agent-service-detected", "agent service in azure.yaml", false}, + {"local.project-endpoint-set", "AZURE_AI_PROJECT_ENDPOINT set", false}, + {"local.agent-yaml-valid", "agent.yaml valid (per service)", false}, + {"local.manual-env-vars", "manual env vars set", false}, + {"local.toolboxes", "Manifest toolboxes have endpoint env vars set", false}, + } + for i, w := range want { + require.Equal(t, w.id, checks[i].ID, "index %d", i) + require.Equal(t, w.name, checks[i].Name, "index %d", i) + require.Equal(t, w.remote, checks[i].Remote, "index %d", i) + require.NotNil(t, checks[i].Fn, "index %d Fn is nil", i) + } +} + +// ---- version comparator ---- + +func TestCompareVersions(t *testing.T) { + t.Parallel() + + cases := []struct { + a, b string + want int + }{ + {"0.1.26-preview", "0.1.27-preview", -1}, + {"0.1.27-preview", "0.1.27-preview", 0}, + {"0.1.28-preview", "0.1.27-preview", 1}, + {"v0.1.27", "0.1.27", 0}, + {"0.1.27+build.42", "0.1.27", 0}, + {"1.0.0-preview", "0.999.999-preview", 1}, + {"0.0.1", "0.1.0", -1}, + // Fail-open: malformed strings compare as equal. + {"not-a-version", "0.1.27", 0}, + {"0.1", "0.1.27", 0}, + {"0.1.27", "not-a-version", 0}, + } + for _, tc := range cases { + t.Run(tc.a+"_vs_"+tc.b, func(t *testing.T) { + t.Parallel() + got := compareVersions(tc.a, tc.b) + require.Equal(t, tc.want, got) + }) + } +} + +func TestParseMainVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want [3]int + ok bool + }{ + {"0.1.27-preview", [3]int{0, 1, 27}, true}, + {"v0.1.27", [3]int{0, 1, 27}, true}, + {" 1.2.3+build.7 ", [3]int{1, 2, 3}, true}, + {"1.2", [3]int{}, false}, + {"1.2.x", [3]int{}, false}, + {"", [3]int{}, false}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + got, ok := parseMainVersion(tc.in) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.want, got) + }) + } +} + +func TestCoalesce(t *testing.T) { + t.Parallel() + + require.Equal(t, "first", coalesce("first", "second")) + require.Equal(t, "second", coalesce("", "second")) + require.Equal(t, "", coalesce("", "", "")) + require.Equal(t, "", coalesce()) +} + +// ---- transport-failure helper ---- + +func TestIsTransportFailure(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + want bool + }{ + {"nil error", nil, false}, + {"plain error (not a status)", errors.New("boom"), false}, + {"Unavailable", status.Error(codes.Unavailable, "x"), true}, + {"DeadlineExceeded", status.Error(codes.DeadlineExceeded, "x"), true}, + // Server-side errors must NOT swap the suggestion: the project / env + // check then reports the real domain failure with its own wording. + {"NotFound", status.Error(codes.NotFound, "x"), false}, + {"Internal", status.Error(codes.Internal, "x"), false}, + {"InvalidArgument", status.Error(codes.InvalidArgument, "x"), false}, + // Canceled is user-initiated, not a transport issue. + {"Canceled", status.Error(codes.Canceled, "x"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, isTransportFailure(tc.err)) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_manual_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_manual_env.go new file mode 100644 index 00000000000..7a26416ee6e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_manual_env.go @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "fmt" + "slices" + "strings" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// newCheckManualEnvVars produces Check `local.manual-env-vars` — the +// "manual config values not set" diagnostic. +// +// "Manual" env vars are values referenced by `${...}` syntax inside an +// agent.yaml whose names are NOT declared as outputs of the project's +// infrastructure (Bicep / Terraform). They are operator-supplied: +// third-party API keys, model deployment names, hand-rolled connection +// strings. They have to be set in the active azd environment before +// `azd ai agent run` (local) or `azd deploy` (Azure) can resolve the +// agent.yaml — otherwise the running agent sees the literal `${KEY}` +// string and almost certainly fails on first use. +// +// The classification of "manual" vs "infra" lives in nextstep's +// AssembleState (the same pipeline that drives the `Next:` renderer's +// per-state guidance). This check forwards the result so the doctor +// surfaces the same signal users see at the end of `azd ai agent init` +// — no second source of truth, no drift. +// +// Source-of-truth: issue Azure/azure-dev#7975 "Example output (project +// ready, but manual config values missing)" lines 117-127. The doctor +// reports the gap; the post-init `Next:` block (resolver.go, manual-vars +// branch) tells the user what to type. +// +// Skip cascade — this check skips when any of the following hold: +// +// - deps.AzdClient is nil (gRPC channel unavailable). Check +// `local.grpc-extension` will already have failed with the actionable +// error. +// - `local.agent-yaml-valid` failed or was skipped. A broken agent.yaml +// produces an empty MissingManualVars (the classifier can't extract +// references it can't parse), which would mislead the user into +// thinking nothing was missing. This guard transitively covers the +// azure-yaml → agent-service-detected → agent-yaml-valid arm of the +// local-check chain (each step's own skip-cascade propagates here). +// - `local.environment-selected` failed or was skipped. +// `nextstep.AssembleState` early-exits its `detectMissingVars` block +// when no env is selected (state.go: `if project != nil && envName != ""`). +// Without this guard the check would silently produce a Pass +// ("no manual env vars are missing") in a state where it never even +// looked at any env values — the exact false-Pass the doctor exists +// to prevent. `environment-selected` is a sibling chain off +// `azure-yaml` (not upstream of `agent-yaml-valid`), so the previous +// guard does not cover it transitively. +// +// On Fail the check lists every missing var in the Message (callers can +// also iterate `Details["missingManualVars"]` for the structured payload). +// The Suggestion picks the first missing var as a paste-ready example +// rather than concatenating one `azd env set` line per var: the formatter +// renders Suggestion as a single line, and a paragraph of newlines would +// break the indentation. Users see the full list in the Message and one +// concrete command to copy-paste. +func newCheckManualEnvVars(deps Dependencies) Check { + return Check{ + ID: "local.manual-env-vars", + Name: "manual env vars set", + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{Status: StatusSkip, Message: "skipped: azd extension not reachable"} + } + if priorBlocked(prior, "local.agent-yaml-valid") { + return Result{Status: StatusSkip, Message: "skipped: agent.yaml check failed or skipped"} + } + if priorBlocked(prior, "local.environment-selected") { + // Without an azd env, AssembleState's detectMissingVars + // block is skipped (state.go:258), so MissingManualVars + // would be empty and the check would falsely Pass. + return Result{ + Status: StatusSkip, + Message: "skipped: no azd environment selected (cannot resolve agent.yaml variables)", + } + } + + assembler := deps.assembleState + if assembler == nil { + assembler = func(c context.Context, client *azdext.AzdClient) (*nextstep.State, []error) { + return nextstep.AssembleState(c, client) + } + } + state, errs := assembler(ctx, deps.AzdClient) + if state == nil { + // AssembleState always returns a non-nil State even when errs + // is non-empty — but defend against a future contract change + // so this check can't be the one to panic-dereference. + cause := "unknown error" + if len(errs) > 0 { + cause = errs[0].Error() + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to assemble agent state: %s", cause), + Suggestion: "Re-run `azd ai agent doctor`; the state assembly returned nil unexpectedly.", + } + } + + missing := slices.Clone(state.MissingManualVars) + slices.Sort(missing) + + if len(missing) == 0 { + return Result{ + Status: StatusPass, + Message: "no manual env vars are missing", + } + } + + // Single-line Suggestion: pin a paste-ready command for the + // first (sorted) missing var, plus a clause pointing at the + // rest only when there ARE additional entries. When exactly + // one var is missing the bare command is the right + // instruction — adding "and likewise for the others" implies + // the user missed something they didn't. + suggestion := fmt.Sprintf("Run `azd env set %s `.", missing[0]) + if len(missing) > 1 { + suggestion += " Repeat for each of the other variables listed above." + } + + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d manual env var(s) referenced by agent.yaml are not set in the azd environment: %s", + len(missing), strings.Join(missing, ", ")), + Suggestion: suggestion, + Details: map[string]any{ + "missingManualVars": missing, + }, + } + }, + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_manual_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_manual_env_test.go new file mode 100644 index 00000000000..a3cfc386071 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_manual_env_test.go @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// ---- Check `local.manual-env-vars` ---- + +// fakeAssembler returns a closure suitable for Dependencies.assembleState. +// Each variant locks one branch of the production check (nil-state, +// nil-state with errs, populated MissingManualVars, etc.) without +// standing up an azd project on disk. +func fakeAssembler( + state *nextstep.State, errs ...error, +) func(context.Context, *azdext.AzdClient) (*nextstep.State, []error) { + return func(_ context.Context, _ *azdext.AzdClient) (*nextstep.State, []error) { + if len(errs) == 0 { + return state, nil + } + return state, errs + } +} + +func TestCheckManualEnvVars_NoClient_Skips(t *testing.T) { + t.Parallel() + + check := newCheckManualEnvVars(Dependencies{AzdClient: nil}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azd extension not reachable") +} + +func TestCheckManualEnvVars_PriorAgentYAMLFailed_Skips(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + // Defensive: if the skip-guard fails, the production path + // would call nextstep.AssembleState against the real (empty) + // client and produce a different Status. The fake assembler + // here asserts the cascade short-circuits before the + // assembler is reached. + assembleState: func(context.Context, *azdext.AzdClient) (*nextstep.State, []error) { + t.Fatalf("assembler should not be called when local.agent-yaml-valid failed") + return nil, nil + }, + }) + + prior := []Result{{ID: "local.agent-yaml-valid", Status: StatusFail}} + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "agent.yaml check failed") +} + +func TestCheckManualEnvVars_PriorAgentYAMLSkipped_AlsoSkips(t *testing.T) { + // Covers the cascade: a deeper upstream (e.g. azure-yaml) failed, + // agent-yaml-valid was therefore skipped, and this check must + // inherit the skip rather than running on a half-loaded project. + // Without this propagation users would see a misleading + // "no manual env vars are missing" Pass underneath the real bug. + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: func(context.Context, *azdext.AzdClient) (*nextstep.State, []error) { + t.Fatalf("assembler should not be called when upstream was skipped") + return nil, nil + }, + }) + + prior := []Result{{ID: "local.agent-yaml-valid", Status: StatusSkip}} + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) +} + +func TestCheckManualEnvVars_PriorEnvironmentSelectedFailed_Skips(t *testing.T) { + // Regression test for issue #7975 false-Pass scenario raised in + // C9 review: when no azd environment is selected, + // nextstep.AssembleState's detectMissingVars block is gated on + // `envName != ""` (state.go) and silently returns an empty + // MissingManualVars. Without an explicit guard against the + // `local.environment-selected` failure, this check would + // produce a Pass ("no manual env vars are missing") in a state + // where it never actually examined any env values. + // + // The fake assembler t.Fatalf's to assert the check + // short-circuits at the guard rather than calling into + // AssembleState — the cascade must be observable in the test, + // not just emergent from the assembler's behavior. + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: func(context.Context, *azdext.AzdClient) (*nextstep.State, []error) { + t.Fatalf("assembler should not be called when local.environment-selected failed") + return nil, nil + }, + }) + + prior := []Result{ + {ID: "local.azure-yaml", Status: StatusPass}, + {ID: "local.environment-selected", Status: StatusFail}, + {ID: "local.agent-service-detected", Status: StatusPass}, + {ID: "local.agent-yaml-valid", Status: StatusPass}, + } + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "no azd environment selected") +} + +func TestCheckManualEnvVars_PriorEnvironmentSelectedSkipped_AlsoSkips(t *testing.T) { + // Symmetric to the failed-environment-selected case: if the env + // check itself was skipped (e.g. azure-yaml failed deeper + // upstream), the manual-env check must also skip rather than + // flake out a confident Pass. + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: func(context.Context, *azdext.AzdClient) (*nextstep.State, []error) { + t.Fatalf("assembler should not be called when env-selected was skipped") + return nil, nil + }, + }) + + prior := []Result{ + {ID: "local.environment-selected", Status: StatusSkip}, + {ID: "local.agent-yaml-valid", Status: StatusPass}, + } + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) +} + +func TestCheckManualEnvVars_AllVarsSet_Passes(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: fakeAssembler(&nextstep.State{HasProjectEndpoint: true}), + }) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, "no manual env vars are missing") + require.Empty(t, got.Suggestion) + require.Nil(t, got.Details) +} + +func TestCheckManualEnvVars_OneMissing_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: fakeAssembler(&nextstep.State{ + HasProjectEndpoint: true, + MissingManualVars: []string{"MY_API_KEY"}, + }), + }) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "1 manual env var(s)") + require.Contains(t, got.Message, "MY_API_KEY") + // Single-var case: bare command, no "repeat" clause — adding it + // would imply the user missed something they didn't. + require.Equal(t, "Run `azd env set MY_API_KEY `.", got.Suggestion) + require.NotContains(t, got.Suggestion, "Repeat") + require.NotContains(t, got.Suggestion, "likewise") + require.Equal(t, []string{"MY_API_KEY"}, got.Details["missingManualVars"]) +} + +func TestCheckManualEnvVars_MultipleMissing_FailsWithSortedList(t *testing.T) { + // Sort order is part of the contract — the rendered Message must + // be deterministic across runs, and the renderer-paired suggestion + // in the nextstep manual-vars branch sorts identically. + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: fakeAssembler(&nextstep.State{ + HasProjectEndpoint: true, + MissingManualVars: []string{"DELTA", "ALPHA", "ECHO", "BRAVO"}, + }), + }) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "4 manual env var(s)") + require.Contains(t, got.Message, "ALPHA, BRAVO, DELTA, ECHO") + // Suggestion uses the first alphabetically sorted var as the + // paste-ready example. Confirms the suggestion text is keyed off + // the SORTED list (not the input order), so the same project + // always yields the same example regardless of map iteration + // order in the upstream classifier. + require.Equal(t, + "Run `azd env set ALPHA `. Repeat for each of the other variables listed above.", + got.Suggestion) + require.Equal(t, + []string{"ALPHA", "BRAVO", "DELTA", "ECHO"}, + got.Details["missingManualVars"]) +} + +func TestCheckManualEnvVars_NilStateFromAssembler_Fails(t *testing.T) { + // Defensive contract test: nextstep.AssembleState today always + // returns a non-nil State. Pin the doctor's defensive branch so + // a future contract drift can't degrade the user-facing report + // into a panic-dereference. + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: fakeAssembler(nil, errors.New("boom: bicep parse failed")), + }) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to assemble agent state") + require.Contains(t, got.Message, "boom: bicep parse failed") + require.Contains(t, got.Suggestion, "state assembly returned nil") +} + +func TestCheckManualEnvVars_NilStateNoErrors_FailsWithFallback(t *testing.T) { + // Edge case: assembler returns (nil, nil). Today unreachable in + // production but the doctor must still produce a non-panicking + // Fail with a sensible message rather than dereferencing nil. + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: fakeAssembler(nil), + }) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to assemble agent state") + require.Contains(t, got.Message, "unknown error") +} + +func TestCheckManualEnvVars_NonFatalErrorsButStateOK_Passes(t *testing.T) { + // nextstep.AssembleState surfaces best-effort errors via the errs + // slice while still returning a usable State. The doctor must + // trust the populated State (PASS when MissingManualVars is empty) + // and not be tripped up by ancillary errs like a missing + // AI_AGENT_PENDING_PROVISION key. + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckManualEnvVars(Dependencies{ + AzdClient: client, + assembleState: fakeAssembler( + &nextstep.State{HasProjectEndpoint: true}, + errors.New("read AI_AGENT_PENDING_PROVISION: key not found"), + ), + }) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) +} + +func TestNewLocalChecks_IncludesManualEnvVarsLast(t *testing.T) { + // Pin C9's insertion point: the manual-env-vars check must follow + // agent-yaml-valid so its skip-cascade against the upstream chain + // is exercised by the runner's prior-results slice. Locks the + // ordering invariant that the design's "checks 1-7" table relies + // on for failure-cascade coherence. + t.Parallel() + + checks := NewLocalChecks(Dependencies{}) + require.NotEmpty(t, checks) + + ids := make([]string, len(checks)) + for i, c := range checks { + ids[i] = c.ID + } + require.Contains(t, ids, "local.manual-env-vars") + + var yamlIdx, manualIdx int = -1, -1 + for i, id := range ids { + switch id { + case "local.agent-yaml-valid": + yamlIdx = i + case "local.manual-env-vars": + manualIdx = i + } + } + require.NotEqual(t, -1, yamlIdx, "agent-yaml-valid must be in NewLocalChecks") + require.NotEqual(t, -1, manualIdx, "manual-env-vars must be in NewLocalChecks") + require.Greater(t, manualIdx, yamlIdx, + "manual-env-vars must come after agent-yaml-valid for the skip-cascade") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_model_deployments.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_model_deployments.go new file mode 100644 index 00000000000..457bb068b43 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_model_deployments.go @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "fmt" + "slices" + "sort" + "strings" + "time" + + "azureaiagent/internal/cmd/nextstep" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// modelDeploymentsProbeTimeout caps the per-account deployments list +// round trip. The doctor remote-checks design budgets 10s per probe +// for one-shot diagnostics (.tmp/pr-8057/azd-ai-agent-doctor-remote +// -checks.md). Deployments lists complete in well under a second in +// practice; the ceiling exists so a stuck VPN or transient ARM hiccup +// surfaces as a clean Skip rather than dragging the whole doctor run. +const modelDeploymentsProbeTimeout = 10 * time.Second + +// modelDeploymentProbeFn is the seam-friendly signature for the +// deployments list probe. The closure receives a per-account ARM +// scope (subscription + resourceGroup + accountName) and returns +// the deployment names that exist under it. Errors short-circuit +// the check to Skip — we can not distinguish "deployment missing" +// from "ARM unreachable" without a successful round trip, and +// surfacing a noisy classification on every transient failure is +// worse than a single Skip with the underlying error verbatim. +type modelDeploymentProbeFn func( + ctx context.Context, + subscriptionID, resourceGroup, accountName string, +) ([]string, error) + +// newCheckModelDeployments produces Check `remote.model-deployments` +// (P5.1 C13). For each `ModelResource` declared in any service's +// `agent.manifest.yaml` (collected by the C2 manifest walker), the +// check queries the Foundry project's underlying Cognitive Services +// account for the matching deployment name. The check Passes when +// every manifest-declared model has a corresponding deployment; +// Fails when one or more deployments are missing. +// +// # Skip cascade +// +// - deps.AzdClient nil → upstream `local.grpc-extension` already +// surfaced the actionable error. +// - `local.environment-selected` failed/skipped → nothing to read +// state from. +// - `local.azure-yaml` or `local.agent-service-detected` failed → +// no services to walk; would Pass falsely if we forged ahead. +// - `remote.auth` failed → ARM probe would 401 identically; let +// the auth check own the diagnosis. +// - `remote.foundry-endpoint` failed → same root cause, same +// remediation. +// - state.HasModels == false → no manifest model declarations; +// the check has nothing to verify. Surface as Skip with a +// short explanation rather than a vacuous Pass. +// - `AZURE_AI_PROJECT_ID` not set / cannot be parsed → can not derive +// the ARM scope to probe. Skip cleanly; the rbac check already +// emits the canonical `azd env set AZURE_AI_PROJECT_ID ...` +// suggestion for the same root cause. +// +// # Aggregation +// +// Deployments live at the Cognitive Services *account* level, not +// the project. The walker may surface ModelRefs from multiple +// services, but every service in an azd project belongs to the same +// Foundry project (and therefore the same account), so the check +// issues exactly one deployments list per run. +// +// # Classification +// +// - All ModelRefs match a deployment name → Pass with the matched +// count. +// - One or more missing → Fail with the missing names listed in +// the Message and structured under `Details["missingModels"]`. +// - Probe error → Skip with the underlying error verbatim. +func newCheckModelDeployments(deps Dependencies) Check { + return Check{ + ID: "remote.model-deployments", + Name: "Manifest model deployments exist in Foundry", + Remote: true, + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable.", + } + } + if priorBlocked(prior, "local.environment-selected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no azd environment is selected " + + "(see check `local.environment-selected`).", + } + } + if priorBlocked(prior, "local.azure-yaml") || + priorBlocked(prior, "local.agent-service-detected") { + return Result{ + Status: StatusSkip, + Message: "skipped: azure.yaml / agent service detection failed " + + "(see checks `local.azure-yaml`, `local.agent-service-detected`).", + } + } + if priorBlocked(prior, "remote.auth") { + return Result{ + Status: StatusSkip, + Message: "skipped: auth probe did not succeed " + + "(see check `remote.auth`).", + } + } + if priorBlocked(prior, "remote.foundry-endpoint") { + return Result{ + Status: StatusSkip, + Message: "skipped: Foundry project endpoint unreachable " + + "(see check `remote.foundry-endpoint`).", + } + } + + assembler := deps.assembleState + if assembler == nil { + assembler = func(c context.Context, client *azdext.AzdClient) (*nextstep.State, []error) { + return nextstep.AssembleState(c, client) + } + } + state, errs := assembler(ctx, deps.AzdClient) + if state == nil { + cause := "unknown error" + if len(errs) > 0 { + cause = errs[0].Error() + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to assemble agent state: %s", cause), + Suggestion: "Re-run `azd ai agent doctor`; the state assembly returned nil unexpectedly.", + } + } + if !state.HasModels { + return Result{ + Status: StatusSkip, + Message: "skipped: no model resources declared in any service's agent.manifest.yaml.", + } + } + + projectIDReader := deps.readProjectResourceIDFn + if projectIDReader == nil { + projectIDReader = readProjectResourceID + } + projectID, err := projectIDReader(ctx, deps.AzdClient) + if err != nil || projectID == "" { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: %s is not set in the current azd environment "+ + "(see check `remote.rbac`).", projectIDVar), + } + } + + sub, rg, account, err := parseAccountFromProjectID(projectID) + if err != nil { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: could not parse account from %s (%s).", + projectIDVar, err), + } + } + + probe := deps.probeModelDeployments + if probe == nil { + probe = realProbeModelDeployments + } + + probeCtx, cancel := context.WithTimeout(ctx, modelDeploymentsProbeTimeout) + defer cancel() + + deployments, err := probe(probeCtx, sub, rg, account) + if err != nil { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: could not list deployments under account %s (%s).", + account, err), + Suggestion: "Retry `azd ai agent doctor`. If the error persists, " + + "verify network reachability to ARM and that your azd login " + + "has read access to the Cognitive Services account.", + } + } + + return classifyModelDeployments(state.ModelRefs, deployments, account) + }, + } +} + +// parseAccountFromProjectID extracts (subscription, resourceGroup, +// accountName) from a Foundry project ARM resource ID of the form +// +// /subscriptions//resourceGroups//providers/ +// Microsoft.CognitiveServices/accounts//projects/ +// +// The parser is intentionally case-insensitive on segment markers +// because ARM occasionally normalizes casing on round-trip. Missing +// any of the three segments returns an error; the check surfaces +// that as Skip with an actionable message pointing at the rbac +// check (which owns the canonical AZURE_AI_PROJECT_ID guidance). +func parseAccountFromProjectID(projectID string) (sub, rg, account string, err error) { + parts := strings.Split(projectID, "/") + for i := 0; i+1 < len(parts); i++ { + switch strings.ToLower(parts[i]) { + case "subscriptions": + sub = parts[i+1] + case "resourcegroups": + rg = parts[i+1] + case "accounts": + account = parts[i+1] + } + } + if sub == "" || rg == "" || account == "" { + return "", "", "", fmt.Errorf("missing subscription / resourceGroup / account in %q", projectID) + } + return sub, rg, account, nil +} + +// classifyModelDeployments produces the Pass/Fail Result by joining +// the manifest's `state.ModelRefs` to the deployments listed under +// the Foundry account. The match is on deployment name only — +// version compatibility surfaces at runtime, not in the doctor. +// +// `account` is forwarded only for human-readable strings; redaction +// is not applied because the account name is the same value the +// user typed into `azd env set AZURE_AI_PROJECT_ID` and is not +// considered sensitive (the project ARN it is parsed from already +// shows up in other doctor checks). +func classifyModelDeployments(refs []nextstep.ResourceRef, deployments []string, account string) Result { + deployed := make(map[string]struct{}, len(deployments)) + for _, name := range deployments { + deployed[name] = struct{}{} + } + + type missingEntry struct { + Name string `json:"name"` + ServiceName string `json:"service"` + } + + var missing []missingEntry + matched := 0 + for _, ref := range refs { + if _, ok := deployed[ref.Name]; ok { + matched++ + continue + } + missing = append(missing, missingEntry{Name: ref.Name, ServiceName: ref.ServiceName}) + } + + sort.Slice(missing, func(i, j int) bool { + if missing[i].Name != missing[j].Name { + return missing[i].Name < missing[j].Name + } + return missing[i].ServiceName < missing[j].ServiceName + }) + + if len(missing) == 0 { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("all %d referenced model deployment(s) present on account %s.", + matched, account), + Details: map[string]any{ + "matchedCount": matched, + "account": account, + }, + } + } + + var sb strings.Builder + for i, m := range missing { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s (service %s)", m.Name, m.ServiceName)) + } + + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d model deployment(s) referenced by agent.manifest.yaml are missing on account %s: %s", + len(missing), account, sb.String()), + Suggestion: "Run `azd provision` to create the missing deployment(s), " + + "or update the agent.manifest.yaml `resources[].name` entries to " + + "match deployments that already exist in Foundry.", + Details: map[string]any{ + "missingModels": missing, + "matchedCount": matched, + "account": account, + }, + } +} + +// realProbeModelDeployments lists every Cognitive Services +// deployment under (subscription, resourceGroup, accountName) using +// the same `armcognitiveservices.DeploymentsClient.NewListPager` +// path that `internal/cmd/init_foundry_resources_helpers.go` +// (`listProjectDeployments`) uses for the init flow. The function +// is the production wiring of `modelDeploymentProbeFn`; tests inject +// a fake via `deps.probeModelDeployments`. +// +// The returned slice contains deployment names only; nothing else +// is currently surfaced because the doctor only needs name-based +// matching. Pager errors short-circuit the call with the wrapped +// error; the check classifies a non-nil error as Skip. +func realProbeModelDeployments( + ctx context.Context, + subscriptionID, resourceGroup, accountName string, +) ([]string, error) { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("create credential: %w", err) + } + return listDeploymentNames(ctx, cred, subscriptionID, resourceGroup, accountName) +} + +// listDeploymentNames is the credential-injecting variant of +// realProbeModelDeployments, factored out so tests that supply a +// fake `azcore.TokenCredential` can exercise the pager / client +// wiring without going through `azd auth`. The function pages +// through every deployment under the account and returns the +// `Name` field of each. +func listDeploymentNames( + ctx context.Context, + credential azcore.TokenCredential, + subscriptionID, resourceGroup, accountName string, +) ([]string, error) { + clientOptions := azure.NewArmClientOptions() + client, err := armcognitiveservices.NewDeploymentsClient(subscriptionID, credential, clientOptions) + if err != nil { + return nil, fmt.Errorf("create deployments client: %w", err) + } + pager := client.NewListPager(resourceGroup, accountName, nil) + var names []string + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("list deployments: %w", err) + } + for _, d := range page.Value { + if d == nil || d.Name == nil { + continue + } + names = append(names, *d.Name) + } + } + slices.Sort(names) + return names, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_model_deployments_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_model_deployments_test.go new file mode 100644 index 00000000000..ce9de935f30 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_model_deployments_test.go @@ -0,0 +1,340 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// validProjectResourceID is a canonical Foundry project ARM resource +// ID used by every model-deployments check test. It must parse cleanly +// through `parseAccountFromProjectID` into +// (subscription=00000000-0000-0000-0000-000000000000, resourceGroup= +// rg-bugbash, accountName=acct-1) so tests can pin the probe arguments. +const validProjectResourceID = "/subscriptions/00000000-0000-0000-0000-000000000000" + + "/resourceGroups/rg-bugbash" + + "/providers/Microsoft.CognitiveServices/accounts/acct-1/projects/proj-1" + +// healthyModelPrior returns the canonical "all upstream checks passed" +// prior result slice that lets the model-deployments check reach its +// own classification logic. Same shape as `healthyPriorResults` from +// checks_agent_status_test.go but with the extra entries the model +// check evaluates (azure.yaml, agent-yaml-valid). +func healthyModelPrior() []Result { + return []Result{ + {ID: "local.azure-yaml", Status: StatusPass}, + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusPass}, + } +} + +// fixedAssembler returns an assembleState stub that yields the given +// State on every call. Used to inject HasModels / ModelRefs without +// touching disk or invoking the production walker. +func fixedAssembler( + state *nextstep.State, +) func(context.Context, *azdext.AzdClient) (*nextstep.State, []error) { + return func(_ context.Context, _ *azdext.AzdClient) (*nextstep.State, []error) { + return state, nil + } +} + +// fixedProjectIDReader returns a readProjectResourceIDFn that yields +// the supplied id (or error) on every call. Mirrors the rbac / +// agent-identity-roles test pattern so the model-deployments tests +// don't need a real azd env. +func fixedProjectIDReader( + id string, err error, +) func(context.Context, *azdext.AzdClient) (string, error) { + return func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return id, err + } +} + +// fixedDeploymentProbe returns a modelDeploymentProbeFn that yields +// the supplied (names, err). Captures the args it was called with via +// the pointers for in-test assertion of probe routing. +func fixedDeploymentProbe( + names []string, err error, captured *[]string, +) modelDeploymentProbeFn { + return func(_ context.Context, sub, rg, account string) ([]string, error) { + if captured != nil { + *captured = []string{sub, rg, account} + } + return names, err + } +} + +func runModelDeploymentsCheck(t *testing.T, deps Dependencies, prior []Result) Result { + t.Helper() + if deps.AzdClient == nil { + deps.AzdClient = &azdext.AzdClient{} + } + c := newCheckModelDeployments(deps) + require.NotNil(t, c.Fn) + require.Equal(t, "remote.model-deployments", c.ID) + require.True(t, c.Remote, "model-deployments check must be tagged remote") + return c.Fn(t.Context(), Options{}, prior) +} + +// ---- Skip-cascade gates ---- + +func TestCheckModelDeployments_SkipsWhenAzdClientNil(t *testing.T) { + t.Parallel() + c := newCheckModelDeployments(Dependencies{}) + res := c.Fn(t.Context(), Options{}, nil) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "azd extension not reachable") +} + +func TestCheckModelDeployments_SkipsCascadeFromUpstream(t *testing.T) { + t.Parallel() + tests := []struct { + name string + failedID string + wantHint string + }{ + {"environment selected blocked", "local.environment-selected", "local.environment-selected"}, + {"azure.yaml blocked", "local.azure-yaml", "local.azure-yaml"}, + {"agent service detected blocked", "local.agent-service-detected", "local.agent-service-detected"}, + {"auth blocked", "remote.auth", "remote.auth"}, + {"foundry-endpoint blocked", "remote.foundry-endpoint", "remote.foundry-endpoint"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Build a prior slice that has only the one failure; + // the check's guards short-circuit on the first + // matching priorBlocked, so we don't need a full + // healthy slice. + prior := []Result{{ID: tc.failedID, Status: StatusFail}} + res := runModelDeploymentsCheck(t, Dependencies{}, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, tc.wantHint) + }) + } +} + +func TestCheckModelDeployments_SkipsWhenNoManifestModels(t *testing.T) { + t.Parallel() + deps := Dependencies{ + assembleState: fixedAssembler(&nextstep.State{HasModels: false}), + probeModelDeployments: fixedDeploymentProbe(nil, nil, nil), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "no model resources declared") +} + +func TestCheckModelDeployments_FailsWhenAssemblerReturnsNilState(t *testing.T) { + t.Parallel() + deps := Dependencies{ + assembleState: func(_ context.Context, _ *azdext.AzdClient) (*nextstep.State, []error) { + return nil, []error{errors.New("manifest.walker: i/o timeout")} + }, + probeModelDeployments: fixedDeploymentProbe(nil, nil, nil), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "failed to assemble agent state") + require.Contains(t, res.Message, "i/o timeout", + "assembler errs[0] should surface in the Fail message") +} + +func TestCheckModelDeployments_SkipsWhenProjectIDUnset(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasModels: true, + ModelRefs: []nextstep.ResourceRef{{Name: "gpt-4o", ServiceName: "chat"}}, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader("", errors.New("not set")), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "AZURE_AI_PROJECT_ID") +} + +func TestCheckModelDeployments_SkipsWhenProjectIDUnparsable(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasModels: true, + ModelRefs: []nextstep.ResourceRef{{Name: "gpt-4o", ServiceName: "chat"}}, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader("garbage", nil), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "could not parse account") +} + +func TestCheckModelDeployments_SkipsWhenProbeErrors(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasModels: true, + ModelRefs: []nextstep.ResourceRef{{Name: "gpt-4o", ServiceName: "chat"}}, + } + var captured []string + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeModelDeployments: fixedDeploymentProbe( + nil, errors.New("ARM transient"), &captured), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "ARM transient") + require.NotEmpty(t, res.Suggestion, "transport-error skip must surface retry guidance") + require.Equal(t, + []string{"00000000-0000-0000-0000-000000000000", "rg-bugbash", "acct-1"}, + captured, + "probe must receive subscription / resourceGroup / accountName parsed from project ID") +} + +// ---- Classification ---- + +func TestCheckModelDeployments_PassesWhenAllRefsMatch(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasModels: true, + ModelRefs: []nextstep.ResourceRef{ + {Name: "gpt-4o", ServiceName: "chat"}, + {Name: "embedding-3-large", ServiceName: "search"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeModelDeployments: fixedDeploymentProbe( + []string{"gpt-4o", "embedding-3-large", "unrelated-other-model"}, + nil, nil), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusPass, res.Status) + require.Contains(t, res.Message, "all 2 referenced model deployment(s) present") + require.Contains(t, res.Message, "acct-1") + require.EqualValues(t, 2, res.Details["matchedCount"]) +} + +func TestCheckModelDeployments_FailsWithMissing(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasModels: true, + ModelRefs: []nextstep.ResourceRef{ + {Name: "gpt-4o", ServiceName: "chat"}, + {Name: "embedding-3-large", ServiceName: "search"}, + {Name: "gpt-4o-mini", ServiceName: "chat"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeModelDeployments: fixedDeploymentProbe( + []string{"gpt-4o"}, // only the first one exists + nil, nil), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "2 model deployment(s)") + require.Contains(t, res.Message, "embedding-3-large (service search)") + require.Contains(t, res.Message, "gpt-4o-mini (service chat)") + require.Contains(t, res.Suggestion, "azd provision") + require.EqualValues(t, 1, res.Details["matchedCount"]) +} + +func TestCheckModelDeployments_FailsWhenAllMissing(t *testing.T) { + t.Parallel() + state := &nextstep.State{ + HasModels: true, + ModelRefs: []nextstep.ResourceRef{ + {Name: "gpt-4o", ServiceName: "chat"}, + }, + } + deps := Dependencies{ + assembleState: fixedAssembler(state), + readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil), + probeModelDeployments: fixedDeploymentProbe([]string{}, nil, nil), + } + res := runModelDeploymentsCheck(t, deps, healthyModelPrior()) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "1 model deployment(s)") + require.Contains(t, res.Message, "gpt-4o (service chat)") +} + +// ---- Parser ---- + +func TestParseAccountFromProjectID(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantSub string + wantRG string + wantAcct string + wantError bool + }{ + { + name: "canonical case", + input: validProjectResourceID, + wantSub: "00000000-0000-0000-0000-000000000000", + wantRG: "rg-bugbash", + wantAcct: "acct-1", + }, + { + name: "mixed-case segment markers", + input: "/SUBSCRIPTIONS/sub-1/RESOURCEGROUPS/rg-1" + + "/providers/Microsoft.CognitiveServices/ACCOUNTS/acct-2/projects/p-2", + wantSub: "sub-1", + wantRG: "rg-1", + wantAcct: "acct-2", + }, + { + name: "missing account segment", + input: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.CognitiveServices", + wantError: true, + }, + { + name: "garbage input", + input: "not-a-resource-id", + wantError: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + sub, rg, acct, err := parseAccountFromProjectID(tc.input) + if tc.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantSub, sub) + require.Equal(t, tc.wantRG, rg) + require.Equal(t, tc.wantAcct, acct) + }) + } +} + +// ---- Factory wiring ---- + +func TestNewCheckModelDeployments_FactoryShape(t *testing.T) { + t.Parallel() + c := newCheckModelDeployments(Dependencies{}) + require.Equal(t, "remote.model-deployments", c.ID) + require.NotEmpty(t, c.Name) + require.True(t, c.Remote, "model deployments check must be tagged remote so --local-only suppresses it") + require.NotNil(t, c.Fn) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project.go new file mode 100644 index 00000000000..14a98ec37e7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project.go @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// agentHost is the value used in azure.yaml for an azure.ai.agent service. +// Must stay in sync with cmd.AiAgentHost ("azure.ai.agent") in +// `internal/cmd/init.go`; duplicated here so the doctor package does not +// have to import cmd (which would form an import cycle once the Cobra +// wiring lands in Phase 4.4). +const agentHost = "azure.ai.agent" + +// projectEndpointVar is the azd environment variable that points at the +// Foundry project. Must stay in sync with the rest of the extension +// (`agent_context.go`, `listen.go`, `service_target_agent.go`). +const projectEndpointVar = "AZURE_AI_PROJECT_ENDPOINT" + +// newCheckAgentServiceDetected produces Check `local.agent-service-detected`. +// It re-fetches the project config and counts services whose `host` is +// `azure.ai.agent`. Pass surfaces the count and service names so users +// can verify the doctor saw what they expected; Fail tells them to run +// `azd ai agent init` to scaffold one. Skips when the gRPC client is +// unavailable or when `local.azure-yaml` failed. +func newCheckAgentServiceDetected(deps Dependencies) Check { + return Check{ + ID: "local.agent-service-detected", + Name: "agent service in azure.yaml", + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{Status: StatusSkip, Message: "skipped: azd extension not reachable"} + } + if priorBlocked(prior, "local.azure-yaml") { + return Result{Status: StatusSkip, Message: "skipped: azure.yaml check failed"} + } + + resp, err := deps.AzdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + suggestion := "Run `azd ai agent init` to add an azure.ai.agent service to azure.yaml." + if isTransportFailure(err) { + suggestion = "Re-run via `azd ai agent doctor`; the extension cannot reach azd's gRPC channel." + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to get project config: %v", err), + Suggestion: suggestion, + } + } + if resp == nil || resp.Project == nil { + return Result{ + Status: StatusFail, + Message: "failed to get project config (is there an azure.yaml?)", + Suggestion: "Run from a directory containing `azure.yaml`, or initialize one with `azd init`.", + } + } + + var agentServices []string + for _, s := range resp.Project.Services { + if s != nil && s.Host == agentHost { + agentServices = append(agentServices, s.Name) + } + } + // Sort for deterministic display: protobuf Services is a map, + // so iteration order is unstable across runs. + sort.Strings(agentServices) + if len(agentServices) == 0 { + return Result{ + Status: StatusFail, + Message: "no `azure.ai.agent` service found in azure.yaml", + Suggestion: "Run `azd ai agent init` to add an azure.ai.agent service to azure.yaml.", + } + } + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "%d agent service(s) in azure.yaml: %s", + len(agentServices), strings.Join(agentServices, ", ")), + Details: map[string]any{ + "agentServices": agentServices, + "agentServiceCount": len(agentServices), + }, + } + }, + } +} + +// newCheckProjectEndpointSet produces Check `local.project-endpoint-set`. +// It reads `AZURE_AI_PROJECT_ENDPOINT` from the currently-selected azd +// environment via the EnvironmentService gRPC. An empty EnvName in +// GetEnvRequest defaults to the current environment, so this check does +// not need to re-resolve the environment name itself. +// +// Skips when the gRPC client is unavailable or when +// `local.environment-selected` failed. Fails when the value is missing +// or empty, telling users to run `azd provision` (the production path) +// or `azd env set` (for pointing at an existing project). +func newCheckProjectEndpointSet(deps Dependencies) Check { + return Check{ + ID: "local.project-endpoint-set", + Name: "AZURE_AI_PROJECT_ENDPOINT set", + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{Status: StatusSkip, Message: "skipped: azd extension not reachable"} + } + if priorBlocked(prior, "local.environment-selected") { + return Result{Status: StatusSkip, Message: "skipped: environment check failed or skipped"} + } + + resp, err := deps.AzdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + Key: projectEndpointVar, + }) + if err != nil { + suggestion := fmt.Sprintf( + "Run `azd provision` to create the Foundry project, or `azd env set %s ` to point at an existing one.", + projectEndpointVar) + if isTransportFailure(err) { + suggestion = "Re-run via `azd ai agent doctor`; the extension cannot reach azd's gRPC channel." + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to read %s: %v", projectEndpointVar, err), + Suggestion: suggestion, + } + } + if resp == nil || strings.TrimSpace(resp.Value) == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("%s is not set in the current azd environment", projectEndpointVar), + Suggestion: fmt.Sprintf( + "Run `azd provision` to create the Foundry project, or `azd env set %s ` to point at an existing one.", + projectEndpointVar), + } + } + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("%s = %s", projectEndpointVar, resp.Value), + Details: map[string]any{ + "projectEndpoint": resp.Value, + }, + } + }, + } +} + +// newCheckAgentYAMLValid produces Check `local.agent-yaml-valid`. For +// each agent service in azure.yaml, it reads `//agent.yaml` +// and parses it as `agent_yaml.ContainerAgent`. Fails when any service's +// file is missing, unreadable, or fails to parse — collecting all errors +// rather than short-circuiting so multi-service projects get a single +// actionable report. +// +// Skips when the gRPC client is unavailable or when +// `local.agent-service-detected` failed (no services to validate). The +// suggestion mirrors the spec's "fix YAML" guidance. +func newCheckAgentYAMLValid(deps Dependencies) Check { + return Check{ + ID: "local.agent-yaml-valid", + Name: "agent.yaml valid (per service)", + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{Status: StatusSkip, Message: "skipped: azd extension not reachable"} + } + if priorBlocked(prior, "local.agent-service-detected") { + return Result{Status: StatusSkip, Message: "skipped: no agent services detected or upstream check blocked"} + } + + resp, err := deps.AzdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + suggestion := "Run from a directory containing `azure.yaml`, or initialize one with `azd init`." + if isTransportFailure(err) { + suggestion = "Re-run via `azd ai agent doctor`; the extension cannot reach azd's gRPC channel." + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to get project config: %v", err), + Suggestion: suggestion, + } + } + if resp == nil || resp.Project == nil { + return Result{ + Status: StatusFail, + Message: "failed to get project config (is there an azure.yaml?)", + Suggestion: "Run from a directory containing `azure.yaml`, or initialize one with `azd init`.", + } + } + + projectPath := resp.Project.Path + // Collect agent service entries in a stable order. protobuf + // `Services` is a map, so iteration order is non-deterministic + // — sorting by service name keeps the failure list (and the + // validatedPaths Detail) reproducible. + type agentSvc struct { + name string + rel string + } + var agents []agentSvc + for _, s := range resp.Project.Services { + if s == nil || s.Host != agentHost { + continue + } + agents = append(agents, agentSvc{name: s.Name, rel: s.RelativePath}) + } + sort.Slice(agents, func(i, j int) bool { return agents[i].name < agents[j].name }) + + var validatedPaths []string + var failures []string + for _, a := range agents { + yamlPath, err := paths.JoinAllowRoot(projectPath, a.rel, "agent.yaml") + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", a.name, err)) + continue + } + if pathErr := validateAgentYAML(yamlPath); pathErr != nil { + failures = append(failures, fmt.Sprintf("%s: %v", a.name, pathErr)) + continue + } + validatedPaths = append(validatedPaths, yamlPath) + } + + if len(failures) > 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "agent.yaml validation failed for %d service(s): %s", + len(failures), strings.Join(failures, "; ")), + Suggestion: "Fix the YAML syntax or ensure agent.yaml exists in each service directory.", + Details: map[string]any{ + "failures": failures, + "validatedPaths": validatedPaths, + }, + } + } + + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("agent.yaml valid for %d service(s)", len(validatedPaths)), + Details: map[string]any{ + "validatedPaths": validatedPaths, + }, + } + }, + } +} + +// validateAgentYAML reads the file at path and runs the same validation +// (`agent_yaml.ValidateAgentDefinition`) that the deploy path uses, so a +// PASS here implies the manifest will not be rejected by deploy for any +// of: missing/invalid `kind`, missing/invalid `name`, or kind-specific +// structural problems. Returns the underlying read/validate error +// verbatim so the caller can attribute it to the offending service. +func validateAgentYAML(path string) error { + //nolint:gosec // path is validated under the project root before this helper is called. + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if err := agent_yaml.ValidateAgentDefinition(data); err != nil { + return fmt.Errorf("validate %s: %w", path, err) + } + return nil +} + +// priorBlocked reports whether the prior results contain a Fail or Skip +// entry for the given check ID. Used for skip-cascade decisions across +// the local-checks chain: when an upstream check is skipped (e.g. +// because *its* upstream failed), downstream checks must also skip +// rather than running on a broken-state assumption — otherwise users +// see misleading remediation for the wrong root cause. +func priorBlocked(prior []Result, id string) bool { + for _, p := range prior { + if p.ID == id && (p.Status == StatusFail || p.Status == StatusSkip) { + return true + } + } + return false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project_test.go new file mode 100644 index 00000000000..73611e139b9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project_test.go @@ -0,0 +1,653 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ---- Check `local.agent-service-detected` ---- + +func TestCheckAgentServiceDetected_NoClient_Skips(t *testing.T) { + t.Parallel() + + check := newCheckAgentServiceDetected(Dependencies{AzdClient: nil}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azd extension not reachable") +} + +func TestCheckAgentServiceDetected_PriorAzureYAMLFailed_Skips(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckAgentServiceDetected(Dependencies{AzdClient: client}) + + prior := []Result{{ID: "local.azure-yaml", Status: StatusFail}} + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azure.yaml check failed") +} + +func TestCheckAgentServiceDetected_GRPCError_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{err: errors.New("rpc boom")}, + &fakeEnvironmentServer{}) + check := newCheckAgentServiceDetected(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to get project config") + require.Contains(t, got.Suggestion, "azd ai agent init") +} + +func TestCheckAgentServiceDetected_TransportError_SwapsSuggestion(t *testing.T) { + t.Parallel() + + for _, code := range []codes.Code{codes.Unavailable, codes.DeadlineExceeded} { + client := newTestAzdClient(t, + &fakeProjectServer{err: status.Error(code, "transport boom")}, + &fakeEnvironmentServer{}) + check := newCheckAgentServiceDetected(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status, "code=%s", code) + require.Contains(t, got.Suggestion, "gRPC channel", "code=%s", code) + require.NotContains(t, got.Suggestion, "azd ai agent init", "code=%s", code) + } +} + +func TestCheckAgentServiceDetected_NilProject_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{}}, // Project: nil + &fakeEnvironmentServer{}) + check := newCheckAgentServiceDetected(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to get project config") +} + +func TestCheckAgentServiceDetected_NoAgentService_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "api": {Name: "api", Host: "containerapp"}, + "web": {Name: "web", Host: "appservice"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentServiceDetected(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "no `azure.ai.agent` service found") + require.Contains(t, got.Suggestion, "azd ai agent init") +} + +func TestCheckAgentServiceDetected_OneAgent_Passes(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "api": {Name: "api", Host: "containerapp"}, + "echo-agent": {Name: "echo-agent", Host: agentHost}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentServiceDetected(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, "1 agent service(s) in azure.yaml: echo-agent") + require.Equal(t, 1, got.Details["agentServiceCount"]) + require.Equal(t, []string{"echo-agent"}, got.Details["agentServices"]) +} + +func TestCheckAgentServiceDetected_MultipleAgents_Passes(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "echo-agent": {Name: "echo-agent", Host: agentHost}, + "summarizer": {Name: "summarizer", Host: agentHost}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentServiceDetected(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, "2 agent service(s)") + require.Contains(t, got.Message, "echo-agent") + require.Contains(t, got.Message, "summarizer") + require.Equal(t, 2, got.Details["agentServiceCount"]) +} + +// ---- Check `local.project-endpoint-set` ---- + +func TestCheckProjectEndpointSet_NoClient_Skips(t *testing.T) { + t.Parallel() + + check := newCheckProjectEndpointSet(Dependencies{AzdClient: nil}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusSkip, got.Status) +} + +func TestCheckProjectEndpointSet_PriorEnvFailed_Skips(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckProjectEndpointSet(Dependencies{AzdClient: client}) + + prior := []Result{{ID: "local.environment-selected", Status: StatusFail}} + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "environment check failed") +} + +func TestCheckProjectEndpointSet_PriorEnvSkipped_AlsoSkips(t *testing.T) { + // Covers the cascade: azure-yaml fails -> environment-selected skips -> + // project-endpoint-set must also skip. Without this propagation, check 5 + // would run against an unloaded env and surface misleading remediation + // for the wrong root cause. + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{}, + // If the check incorrectly proceeds past the guard it would call + // GetValue; set valueErr so we'd see the wrong-path Fail in the + // assertion instead of a quiet Skip. + &fakeEnvironmentServer{valueErr: errors.New("should not be called")}) + check := newCheckProjectEndpointSet(Dependencies{AzdClient: client}) + + prior := []Result{{ID: "local.environment-selected", Status: StatusSkip}} + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "environment check failed or skipped") +} + +func TestCheckProjectEndpointSet_GRPCError_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{valueErr: errors.New("rpc boom")}) + check := newCheckProjectEndpointSet(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to read AZURE_AI_PROJECT_ENDPOINT") + require.Contains(t, got.Suggestion, "azd env set AZURE_AI_PROJECT_ENDPOINT") +} + +func TestCheckProjectEndpointSet_TransportError_SwapsSuggestion(t *testing.T) { + t.Parallel() + + for _, code := range []codes.Code{codes.Unavailable, codes.DeadlineExceeded} { + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{valueErr: status.Error(code, "transport boom")}) + check := newCheckProjectEndpointSet(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status, "code=%s", code) + require.Contains(t, got.Suggestion, "gRPC channel", "code=%s", code) + require.NotContains(t, got.Suggestion, "azd env set", "code=%s", code) + } +} + +func TestCheckProjectEndpointSet_EmptyValue_Fails(t *testing.T) { + t.Parallel() + + for _, val := range []string{"", " "} { + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{valueResp: &azdext.KeyValueResponse{ + Key: projectEndpointVar, + Value: val, + }}) + check := newCheckProjectEndpointSet(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status, "value=%q", val) + require.Contains(t, got.Message, "is not set", "value=%q", val) + require.Contains(t, got.Suggestion, "azd provision", "value=%q", val) + } +} + +func TestCheckProjectEndpointSet_NilResp_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{valueResp: nil}) + check := newCheckProjectEndpointSet(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) +} + +func TestCheckProjectEndpointSet_ValidValue_Passes(t *testing.T) { + t.Parallel() + + const endpoint = "https://my-project.services.ai.azure.com/api/projects/foo" + client := newTestAzdClient(t, + &fakeProjectServer{}, + &fakeEnvironmentServer{valueResp: &azdext.KeyValueResponse{ + Key: projectEndpointVar, + Value: endpoint, + }}) + check := newCheckProjectEndpointSet(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, endpoint) + require.Equal(t, endpoint, got.Details["projectEndpoint"]) +} + +// ---- Check `local.agent-yaml-valid` ---- + +func TestCheckAgentYAMLValid_NoClient_Skips(t *testing.T) { + t.Parallel() + + check := newCheckAgentYAMLValid(Dependencies{AzdClient: nil}) + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusSkip, got.Status) +} + +func TestCheckAgentYAMLValid_PriorAgentDetectionFailed_Skips(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, &fakeProjectServer{}, &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + prior := []Result{{ID: "local.agent-service-detected", Status: StatusFail}} + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "no agent services detected") +} + +func TestCheckAgentYAMLValid_PriorAgentDetectionSkipped_AlsoSkips(t *testing.T) { + // Covers the cascade: azure-yaml fails -> agent-service-detected skips -> + // agent-yaml-valid must also skip. Without this propagation, check 6 + // would re-fetch the project (failing identically to check 2) and + // surface a duplicate failure for the same root cause. + t.Parallel() + + client := newTestAzdClient(t, + // Server set up to fail if reached, to ensure the guard short-circuits. + &fakeProjectServer{err: errors.New("should not be called")}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + prior := []Result{{ID: "local.agent-service-detected", Status: StatusSkip}} + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "no agent services detected or upstream check blocked") +} + +func TestCheckAgentYAMLValid_GRPCError_Fails(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{err: errors.New("rpc boom")}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "failed to get project config") +} + +func TestCheckAgentYAMLValid_TransportError_SwapsSuggestion(t *testing.T) { + t.Parallel() + + client := newTestAzdClient(t, + &fakeProjectServer{err: status.Error(codes.Unavailable, "transport boom")}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Suggestion, "gRPC channel") +} + +func TestCheckAgentYAMLValid_OneServiceValid_Passes(t *testing.T) { + t.Parallel() + + projectPath := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectPath, "src", "agent"), 0o750)) + writeYAML(t, projectPath, "src/agent/agent.yaml", ` +kind: hosted +name: echo-agent +language: python +entrypoint: main.py +protocols: + - protocol: invocations + version: "1" +`) + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "echo-agent": {Name: "echo-agent", Host: agentHost, RelativePath: "src/agent"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, "agent.yaml valid for 1 service(s)") +} + +func TestCheckAgentYAMLValid_NonAgentServicesIgnored(t *testing.T) { + t.Parallel() + + projectPath := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectPath, "src", "agent"), 0o750)) + writeYAML(t, projectPath, "src/agent/agent.yaml", "kind: hosted\nname: echo\nlanguage: python\n") + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "api": {Name: "api", Host: "containerapp", RelativePath: "src/api"}, + "echo-agent": {Name: "echo-agent", Host: agentHost, RelativePath: "src/agent"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusPass, got.Status, "api service has no agent.yaml — must be skipped, not failed") + paths, ok := got.Details["validatedPaths"].([]string) + require.True(t, ok) + require.Len(t, paths, 1) + require.Contains(t, paths[0], "src"+string(filepath.Separator)+"agent") +} + +func TestCheckAgentYAMLValid_MissingFile_Fails(t *testing.T) { + t.Parallel() + + projectPath := t.TempDir() + // Note: no agent.yaml file created. + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "echo-agent": {Name: "echo-agent", Host: agentHost, RelativePath: "src/agent"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "echo-agent") + require.Contains(t, got.Suggestion, "Fix the YAML") + failures, ok := got.Details["failures"].([]string) + require.True(t, ok) + require.Len(t, failures, 1) +} + +func TestCheckAgentYAMLValid_MalformedYAML_Fails(t *testing.T) { + t.Parallel() + + projectPath := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectPath, "src", "agent"), 0o750)) + writeYAML(t, projectPath, "src/agent/agent.yaml", "name: echo\n bad-indent: oops\n: missing-key\n") + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "echo-agent": {Name: "echo-agent", Host: agentHost, RelativePath: "src/agent"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "echo-agent") + failures, ok := got.Details["failures"].([]string) + require.True(t, ok) + require.Len(t, failures, 1) +} + +func TestCheckAgentYAMLValid_MixedValidAndInvalid_Fails(t *testing.T) { + t.Parallel() + + projectPath := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectPath, "src", "ok"), 0o750)) + require.NoError(t, os.MkdirAll(filepath.Join(projectPath, "src", "bad"), 0o750)) + writeYAML(t, projectPath, "src/ok/agent.yaml", "kind: hosted\nname: ok-agent\nlanguage: python\n") + // bad: malformed yaml (mapping key with no value, broken indent). + writeYAML(t, projectPath, "src/bad/agent.yaml", "name: bad\n : nope\n\t- tabs-here\n") + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "ok-agent": {Name: "ok-agent", Host: agentHost, RelativePath: "src/ok"}, + "bad-agent": {Name: "bad-agent", Host: agentHost, RelativePath: "src/bad"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "1 service(s)") // 1 failure + require.Contains(t, got.Message, "bad-agent") + require.NotContains(t, got.Message, "ok-agent: ") // ok-agent should not be in the failures list + + failures, ok := got.Details["failures"].([]string) + require.True(t, ok) + require.Len(t, failures, 1) + + validated, ok := got.Details["validatedPaths"].([]string) + require.True(t, ok) + require.Len(t, validated, 1) +} + +// ---- helper: priorBlocked ---- + +func TestPriorBlocked(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + prior []Result + id string + want bool + }{ + {"empty prior", nil, "x", false}, + {"matching fail", []Result{{ID: "x", Status: StatusFail}}, "x", true}, + {"matching pass", []Result{{ID: "x", Status: StatusPass}}, "x", false}, + // Skip propagates blocking: if upstream skipped (because *its* upstream failed), + // downstream checks must also skip rather than run on broken assumptions. + {"matching skip", []Result{{ID: "x", Status: StatusSkip}}, "x", true}, + {"matching warn", []Result{{ID: "x", Status: StatusWarn}}, "x", false}, + {"different id fail", []Result{{ID: "y", Status: StatusFail}}, "x", false}, + {"different id skip", []Result{{ID: "y", Status: StatusSkip}}, "x", false}, + {"id matches middle entry fail", []Result{ + {ID: "a", Status: StatusPass}, + {ID: "x", Status: StatusFail}, + {ID: "c", Status: StatusPass}, + }, "x", true}, + {"id matches middle entry skip", []Result{ + {ID: "a", Status: StatusPass}, + {ID: "x", Status: StatusSkip}, + {ID: "c", Status: StatusPass}, + }, "x", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, priorBlocked(tc.prior, tc.id)) + }) + } +} + +func TestCheckAgentYAMLValid_MissingKind_Fails(t *testing.T) { + // Without explicit `kind:`, ValidateAgentDefinition rejects the manifest + // because kind is required. Doctor must catch this pre-flight rather + // than letting deploy be the first place that surfaces it. + t.Parallel() + + projectPath := t.TempDir() + writeYAML(t, projectPath, "src/agent/agent.yaml", "name: echo-agent\nlanguage: python\n") + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "echo-agent": {Name: "echo-agent", Host: agentHost, RelativePath: "src/agent"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "echo-agent") + failures, ok := got.Details["failures"].([]string) + require.True(t, ok) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "kind") +} + +func TestCheckAgentYAMLValid_InvalidKind_Fails(t *testing.T) { + // A `kind` that isn't in ValidAgentKinds() (hosted/workflow) must be + // rejected. Bare yaml.Unmarshal would silently accept this; the + // production deploy path rejects it via ValidateAgentDefinition. + t.Parallel() + + projectPath := t.TempDir() + writeYAML(t, projectPath, "src/agent/agent.yaml", "kind: nonsense\nname: echo-agent\nlanguage: python\n") + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "echo-agent": {Name: "echo-agent", Host: agentHost, RelativePath: "src/agent"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + failures, ok := got.Details["failures"].([]string) + require.True(t, ok) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "kind") +} + +func TestCheckAgentYAMLValid_InvalidName_Fails(t *testing.T) { + // Agent name must match `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$` + // (DNS-style). An underscore is invalid for deployable agent names. + // Doctor must surface this before deploy, not after. + t.Parallel() + + projectPath := t.TempDir() + writeYAML(t, projectPath, "src/agent/agent.yaml", "kind: hosted\nname: My_Agent\nlanguage: python\n") + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "my-agent": {Name: "my-agent", Host: agentHost, RelativePath: "src/agent"}, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentYAMLValid(Dependencies{AzdClient: client}) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + failures, ok := got.Details["failures"].([]string) + require.True(t, ok) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "name") +} + +// writeYAML is a tiny test helper that writes the given content to +// / after ensuring the parent directory exists. +func writeYAML(t *testing.T, root, rel, content string) { + t.Helper() + full := filepath.Join(root, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o600)) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_rbac.go new file mode 100644 index 00000000000..a699fb0e0d7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_rbac.go @@ -0,0 +1,442 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// rbacLearnLink is the canonical learn.microsoft.com link surfaced +// alongside the templated `az role assignment create` suggestion when +// the developer is missing the Azure AI User role on the Foundry +// project. Same target as `rbacLink` in checks_foundry_endpoint.go, +// but pinned separately here so the two checks can drift to different +// resources later (e.g., if Foundry publishes a dedicated "developer +// onboarding" page) without an awkward cross-file rename. +const rbacLearnLink = "https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry" + +// projectIDVar is the azd environment variable that carries the full +// ARM resource ID of the Foundry project. It is NOT the same as +// `AZURE_AI_PROJECT_ENDPOINT` (which is the data-plane URL); RBAC +// queries run against ARM and need the full resource ID to scope the +// role-assignment list. +const projectIDVar = "AZURE_AI_PROJECT_ID" + +// redactedPlaceholder is the canonical scrubbed value rendered in +// user-facing strings (Message / Suggestion / Details) when +// Options.Unredacted is false (the default). Matches the convention +// the design's "Redaction in non-interactive output" section calls +// out (line 177 of azd-ai-agent-doctor-remote-checks.md). +const redactedPlaceholder = "" + +// shellSafePlaceholderID and shellSafePlaceholderScope are the +// placeholder tokens used in templated `az role assignment create` +// commands. They DO NOT use `<...>` angle brackets because bash and +// zsh interpret `` as input redirection — a user who literally +// copy-pastes `--assignee ` into a shell gets +// `redacted: No such file or directory` rather than a useful error. +// The az-doc convention is to use ALL_CAPS placeholders for tokens +// the user is expected to substitute, so we match that pattern. +const ( + shellSafePlaceholderID = "OBJECT_ID" + shellSafePlaceholderScope = "PROJECT_SCOPE" +) + +// redactedDisplayLabel is the user-facing substitute for a real +// PrincipalDisplay when Options.Unredacted is false. We don't use +// `` here because the Message reads " has the +// required role on project '...'" — a sentence with `` in +// it looks like a templating bug, while "the current principal" +// reads naturally and matches the empty-display-name fallback at +// the same call site. +const redactedDisplayLabel = "the current principal" + +// scopeRedactRE captures any ARM scope substring of the form +// `/subscriptions/[/resourceGroups/[/providers///...]]`. +// Used to scrub raw scope ARNs out of probe error text before the +// doctor surfaces it in Message / Details when redaction is on. The +// regex deliberately matches greedily but stops at whitespace and +// at characters not valid in ARM resource ID segments (quotes, +// commas, parentheses, colons) so adjacent prose ("at scope ...: 403") +// survives intact around the redacted scope. +var scopeRedactRE = regexp.MustCompile( + `/subscriptions/[^/\s"',\):;\]]+(?:/[^/\s"',\):;\]]+)*`, +) + +// guidRedactRE captures bare GUIDs (subscription IDs, tenant IDs, +// principal OIDs). Used as a second pass after scopeRedactRE so +// any GUID that escaped the scope match (e.g., bare in an error +// body) is also scrubbed. +var guidRedactRE = regexp.MustCompile( + `\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`, +) + +// newCheckRBAC produces Check `remote.rbac`. It queries the +// developer's role assignments on the Foundry project's ARM scope +// and surfaces: +// +// - Pass: " has Azure AI access on project '/'" +// (Details: matched role family, scope, principal ID) +// - Fail: " lacks Azure AI access" with a templated +// `az role assignment create --role "Azure AI User" --assignee +// --scope ` command and a learn.microsoft.com link. +// The Suggestion text redacts the principal ID and scope ARN +// when Options.Unredacted is false (the default), so doctor's +// `--output json` consumers cannot accidentally exfiltrate +// identifiers through a pipeline. +// - Skip: precondition unmet (auth failed, env missing the project +// resource ID, or a transient Graph / ARM error). +// +// Per the design dependency matrix (line 115 of +// azd-ai-agent-doctor-remote-checks.md), RBAC reads ARM, not the +// Foundry data plane — so this check deliberately does NOT cascade +// from `remote.foundry-endpoint`. If a user has the role but the +// data-plane probe is failing for an unrelated reason (DNS / proxy / +// transient outage), this check still produces a useful Pass. +// +// The check is read-only. Unlike `project.CheckDeveloperRBAC`, it +// never attempts to auto-assign missing roles — doctor's contract is +// diagnostic-only. The templated `az role assignment create` command +// lets a user (or a privileged operator they share the output with) +// apply the fix explicitly. +func newCheckRBAC(deps Dependencies) Check { + return Check{ + ID: "remote.rbac", + Name: "Developer has required role on Foundry project", + Remote: true, + Fn: func(ctx context.Context, opts Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable.", + } + } + if priorBlocked(prior, "local.environment-selected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no azd environment is selected " + + "(see check `local.environment-selected`).", + } + } + if priorBlocked(prior, "remote.auth") { + return Result{ + Status: StatusSkip, + Message: "skipped: auth probe did not succeed " + + "(see check `remote.auth`).", + } + } + + projectIDReader := deps.readProjectResourceIDFn + if projectIDReader == nil { + projectIDReader = readProjectResourceID + } + projectID, err := projectIDReader(ctx, deps.AzdClient) + if err != nil { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: could not read %s from the current "+ + "azd environment (%s).", + projectIDVar, err), + Suggestion: fmt.Sprintf( + "Run `azd provision` to create the Foundry "+ + "project, or `azd env set %s "+ + "` to "+ + "point at an existing one.", + projectIDVar), + } + } + if projectID == "" { + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: %s is not set in the current azd "+ + "environment.", projectIDVar), + Suggestion: fmt.Sprintf( + "Run `azd provision` to create the Foundry "+ + "project, or `azd env set %s "+ + "` to "+ + "point at an existing one.", + projectIDVar), + } + } + + // Validate the resource ID shape upfront. A malformed + // AZURE_AI_PROJECT_ID is a configuration error — the + // user fixes it with `azd env set`, not by retrying + // network probes. Catching it here avoids emitting a + // misleading "check your network reachability" Suggestion + // for what is purely a typo / wrong value (per Sonnet 4.6 + // review finding on commit 0c4d5ee31). + if err := project.ValidateProjectResourceID(projectID); err != nil { + details := map[string]any{ + "projectId": redactScope(projectID, opts.Unredacted), + } + if opts.Unredacted { + details["validateError"] = err.Error() + } + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: %s is not a valid Foundry project ARM "+ + "resource ID.", projectIDVar), + Suggestion: fmt.Sprintf( + "Set %s to an ARM resource ID like "+ + "`/subscriptions//resourceGroups//"+ + "providers/Microsoft.CognitiveServices/accounts/"+ + "/projects/` with `azd env set %s "+ + "`. Run `azd provision` if the project "+ + "does not yet exist.", + projectIDVar, projectIDVar), + Details: details, + } + } + + probe := deps.probeDeveloperRBAC + if probe == nil { + probe = project.QueryDeveloperRBAC + } + res, err := probe(ctx, deps.AzdClient, projectID) + if err != nil { + return classifyRBACProbeError(err, projectID, opts.Unredacted) + } + + return classifyRBACResult(res, opts.Unredacted) + }, + } +} + +// classifyRBACProbeError maps a non-nil `QueryDeveloperRBAC` error +// onto a Skip Result with the most-specific user guidance available. +// Each branch is keyed on the canonical sentinel errors defined in +// the `project` package — sentinel-based detection survives wording +// changes in wrapped error text, and keeps the doctor in lockstep +// with the probe's error contract. +// +// Output redaction: scope ARNs and GUIDs are scrubbed from the +// rendered Message and from `Details["probeError"]` when +// !unredacted. `Details["probeError"]` is OMITTED entirely in the +// default (redacted) mode for non-sentinel errors, because ARM +// response bodies can carry response-body identifiers (assignment +// names, action lists) that we can't reliably enumerate. +func classifyRBACProbeError(err error, projectID string, unredacted bool) Result { + // Cancellation propagates as a clean Skip — user aborted, this + // is not an RBAC failure. + if errors.Is(err, context.Canceled) { + return Result{ + Status: StatusSkip, + Message: "skipped: RBAC probe was cancelled.", + } + } + + // Service-principal sign-in: Graph /me is user-delegated only. + // Surface a SPN-aware Skip rather than letting the user chase a + // generic transient retry hint. + if errors.Is(err, project.ErrSPNDelegatedAuthRequired) { + return Result{ + Status: StatusSkip, + Message: "skipped: RBAC check supports user-delegated " + + "sign-in only; a service-principal token was detected.", + Suggestion: "Sign in with a user identity via " + + "`azd auth login` to enable the RBAC check, or verify " + + "the role assignment manually with " + + "`az role assignment list --assignee " + + "--scope `.", + } + } + + // Defensive: ErrInvalidProjectResourceID should not reach this + // branch because the upfront validation catches it, but + // QueryDeveloperRBAC also wraps this sentinel — handle it here + // so a future code path that bypasses the validation still + // produces useful guidance. + if errors.Is(err, project.ErrInvalidProjectResourceID) { + details := map[string]any{ + "projectId": redactScope(projectID, unredacted), + } + if unredacted { + details["probeError"] = err.Error() + } + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: %s is not a valid Foundry project ARM "+ + "resource ID.", projectIDVar), + Suggestion: fmt.Sprintf( + "Set %s to an ARM resource ID with "+ + "`azd env set %s `, or run "+ + "`azd provision` to create one.", + projectIDVar, projectIDVar), + Details: details, + } + } + + // Generic transient probe error. Sanitize the rendered error + // text by redacting any ARM scope substring or GUID. + displayErr := firstLine(err.Error()) + if !unredacted { + displayErr = sanitizeScopeARNs(displayErr) + } + details := map[string]any{ + "projectId": redactScope(projectID, unredacted), + } + if unredacted { + // Only carry the raw probe error in Details when explicitly + // unredacted — otherwise it can echo subscription IDs / RG + // names / response-body fragments past the redaction layer. + details["probeError"] = err.Error() + } + return Result{ + Status: StatusSkip, + Message: fmt.Sprintf( + "skipped: could not query role assignments (%s).", + displayErr), + Suggestion: "Retry `azd ai agent doctor` after a moment; " + + "if the failure persists, check `azd auth login` " + + "output and your network reachability to " + + "`graph.microsoft.com` and `management.azure.com`.", + Details: details, + } +} + +// sanitizeScopeARNs scrubs ARM scope substrings and bare GUIDs out +// of arbitrary text. Used to redact probe error messages before +// they hit the user-facing Message / Details surface. Idempotent. +func sanitizeScopeARNs(text string) string { + text = scopeRedactRE.ReplaceAllString(text, redactedPlaceholder) + text = guidRedactRE.ReplaceAllString(text, redactedPlaceholder) + return text +} + +// classifyRBACResult maps a project.DeveloperRBACResult onto a +// doctor Result, handling the redaction switch in one place. Pulled +// out as a free function so unit tests can pin the Pass / Fail +// templating directly without standing up a fake probe. +func classifyRBACResult(res *project.DeveloperRBACResult, unredacted bool) Result { + // PrincipalDisplay can carry UPN fragments (e.g., + // `Alice Example (alice@contoso.com)`) so the default redacted + // mode substitutes a generic label in the Message rather than + // echoing the raw display. The empty-display fallback uses + // the same label so the Pass / Fail sentence reads naturally + // in both redacted and missing-display modes. + displayName := res.PrincipalDisplay + if !unredacted || displayName == "" { + displayName = redactedDisplayLabel + } + scopeShort := fmt.Sprintf("%s/%s", res.AccountName, res.ProjectName) + details := map[string]any{ + "hasSufficientAIRole": res.HasSufficientAIRole, + "accountName": res.AccountName, + "projectName": res.ProjectName, + "principalId": redactID(res.PrincipalID, unredacted), + "projectScope": redactScope(res.ProjectScope, unredacted), + "principalDisplay": redactDisplay(res.PrincipalDisplay, unredacted), + } + + if res.HasSufficientAIRole { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "%s has the required role on project '%s'.", + displayName, scopeShort), + Details: details, + } + } + + // Templated `az` command: in redacted mode use shell-safe + // ALL_CAPS placeholders (NOT ``, because bash/zsh + // interpret `` as input redirection — a literal + // copy-paste of `--assignee ` would fail with + // `redacted: No such file or directory`). + principalArg := res.PrincipalID + scopeArg := res.ProjectScope + if !unredacted { + principalArg = shellSafePlaceholderID + scopeArg = shellSafePlaceholderScope + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%s does not have the required role on project '%s' "+ + "(Azure AI User / Azure AI Developer / Contributor / Owner).", + displayName, scopeShort), + Suggestion: fmt.Sprintf( + "Assign the Azure AI User role to the developer with:\n"+ + " az role assignment create \\\n"+ + " --role \"Azure AI User\" \\\n"+ + " --assignee %s \\\n"+ + " --scope %q", + principalArg, scopeArg), + Links: []string{rbacLearnLink}, + Details: details, + } +} + +// readProjectResourceID pulls AZURE_AI_PROJECT_ID from the active +// azd environment via the EnvironmentService gRPC. Returns an empty +// string when the value is missing or whitespace-only; callers +// distinguish that from an outright error. +func readProjectResourceID(ctx context.Context, azdClient *azdext.AzdClient) (string, error) { + resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + Key: projectIDVar, + }) + if err != nil { + return "", err + } + if resp == nil { + return "", nil + } + return strings.TrimSpace(resp.Value), nil +} + +// redactID returns the value verbatim when unredacted is true, +// otherwise the canonical redacted placeholder. Centralizing the +// branch here keeps the Details / Suggestion / Message redaction +// rules in lock-step. +func redactID(id string, unredacted bool) string { + if unredacted { + return id + } + if id == "" { + return "" + } + return redactedPlaceholder +} + +// redactScope mirrors redactID for ARM resource IDs. Pinned as its +// own helper so future evolution (e.g., showing a host-only short +// form when redacted) doesn't have to thread a "type" parameter +// through every call site. +func redactScope(scope string, unredacted bool) string { + if unredacted { + return scope + } + if scope == "" { + return "" + } + return redactedPlaceholder +} + +// redactDisplay returns the full display name when unredacted is +// true, otherwise the placeholder. Display names can contain a UPN +// fragment (e.g., "Alice Example (alice@contoso.com)") so we redact +// by default; the Message rendering still uses the bare display +// name from PrincipalDisplay for readability. +func redactDisplay(display string, unredacted bool) string { + if unredacted { + return display + } + if display == "" { + return "" + } + return redactedPlaceholder +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_rbac_test.go new file mode 100644 index 00000000000..6dbce0dd5e0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_rbac_test.go @@ -0,0 +1,748 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "fmt" + "testing" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// rbacProbeStub builds a Dependencies whose probeDeveloperRBAC seam +// returns a fixed (result, err) pair. AzdClient is set to a non-nil +// placeholder so the early `deps.AzdClient == nil` Skip does not +// short-circuit the check. The placeholder is never dereferenced +// inside the check body (the seam intercepts before any client call +// happens), so a zero-value AzdClient is safe. +func rbacProbeStub(result *project.DeveloperRBACResult, err error) Dependencies { + return Dependencies{ + AzdClient: &azdext.AzdClient{}, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return result, err + }, + } +} + +// passingPriorsForRBAC returns the upstream prior results the RBAC +// check requires to actually run: environment-selected + auth Pass. +// Notably it does NOT include a remote.foundry-endpoint result +// because the design's dependency matrix (line 115) explicitly +// excludes that cascade. +func passingPriorsForRBAC() []Result { + return []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "remote.auth", Status: StatusPass}, + } +} + +// ---- Skip-cascade contract ---- + +func TestCheckRBAC_SkipsWhenAzdClientMissing(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + t.Fatal("probe must not be invoked when AzdClient is nil") + return nil, nil + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azd extension not reachable") +} + +func TestCheckRBAC_SkipsWhenEnvNotSelected(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(rbacProbeStub(nil, errors.New("probe should not have been called"))) + prior := []Result{ + {ID: "local.environment-selected", Status: StatusFail}, + {ID: "remote.auth", Status: StatusPass}, + } + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "azd environment") + require.Contains(t, got.Message, "local.environment-selected") +} + +func TestCheckRBAC_SkipsWhenAuthFailed(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(rbacProbeStub(nil, errors.New("probe should not have been called"))) + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "remote.auth", Status: StatusFail}, + } + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "auth probe") + require.Contains(t, got.Message, "remote.auth") +} + +// Per the design's dependency matrix (line 115), RBAC reads ARM and +// is NOT dependent on the Foundry data-plane reachability check. A +// failing `remote.foundry-endpoint` (e.g., a transient DNS hiccup) +// should NOT prevent the user from learning that their role +// assignment is missing. +func TestCheckRBAC_DoesNotSkipOnFoundryEndpointFail(t *testing.T) { + t.Parallel() + + probeCalled := false + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, projectID string) (*project.DeveloperRBACResult, error) { + probeCalled = true + require.Equal(t, + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", + projectID) + return &project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "Alice", + HasSufficientAIRole: true, + ProjectScope: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", + AccountName: "acct", + ProjectName: "proj", + }, nil + }, + }) + // Mark `remote.foundry-endpoint` as Fail in priors — the RBAC + // check should still run because its skip-cascade explicitly + // excludes the data-plane check. + prior := []Result{ + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "remote.auth", Status: StatusPass}, + {ID: "remote.foundry-endpoint", Status: StatusFail}, + } + + got := check.Fn(t.Context(), Options{}, prior) + + require.Equal(t, StatusPass, got.Status, + "RBAC check must Pass even when foundry-endpoint check Failed") + require.True(t, probeCalled, + "probe must have been invoked despite the foundry-endpoint Fail in priors") +} + +// ---- classifyRBACResult: Pass / Fail mapping (the heart of the check) ---- + +func TestClassifyRBACResult_PassesWhenRoleHeld(t *testing.T) { + t.Parallel() + + got := classifyRBACResult(&project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "Alice Example", + HasSufficientAIRole: true, + ProjectScope: "/subscriptions/sub/rg/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", + AccountName: "acct", + ProjectName: "proj", + }, false /* redacted */) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, redactedDisplayLabel, + "default redacted mode must use the generic display label, not the raw PrincipalDisplay") + require.NotContains(t, got.Message, "Alice Example", + "raw PrincipalDisplay must not leak in default redacted mode (UPN safety)") + require.Contains(t, got.Message, "acct/proj") + require.Empty(t, got.Suggestion, + "Pass results should not carry a Suggestion") + require.Empty(t, got.Links, + "Pass results should not carry Links") + // Details should be present and indicate the result, but principal + // id and scope are redacted in the default mode. + require.Equal(t, true, got.Details["hasSufficientAIRole"]) + require.Equal(t, "acct", got.Details["accountName"]) + require.Equal(t, "proj", got.Details["projectName"]) + require.Equal(t, redactedPlaceholder, got.Details["principalId"]) + require.Equal(t, redactedPlaceholder, got.Details["projectScope"]) + require.Equal(t, redactedPlaceholder, got.Details["principalDisplay"]) +} + +func TestClassifyRBACResult_PassesAndPreservesIdentitiesWhenUnredacted(t *testing.T) { + t.Parallel() + + scope := "/subscriptions/sub/rg/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj" + got := classifyRBACResult(&project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "Alice Example", + HasSufficientAIRole: true, + ProjectScope: scope, + AccountName: "acct", + ProjectName: "proj", + }, true /* unredacted */) + + require.Equal(t, StatusPass, got.Status) + require.Equal(t, "principal-oid", got.Details["principalId"]) + require.Equal(t, scope, got.Details["projectScope"]) + require.Equal(t, "Alice Example", got.Details["principalDisplay"]) +} + +func TestClassifyRBACResult_FailsWithTemplatedAzCommand(t *testing.T) { + t.Parallel() + + scope := "/subscriptions/sub/rg/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj" + got := classifyRBACResult(&project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "Alice Example", + HasSufficientAIRole: false, + ProjectScope: scope, + AccountName: "acct", + ProjectName: "proj", + }, false /* redacted */) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, redactedDisplayLabel, + "default redacted mode must use the generic display label in the Message") + require.NotContains(t, got.Message, "Alice Example", + "raw PrincipalDisplay must not leak in default redacted Fail Message") + require.Contains(t, got.Message, "acct/proj") + require.Contains(t, got.Message, "does not have the required role") + + // The suggestion must contain the templated az command, with + // shell-safe ALL_CAPS placeholders in the default redacted mode + // (NOT ``, because bash/zsh treat `` as input + // redirection — a literal copy-paste must fail safely or work). + require.Contains(t, got.Suggestion, "az role assignment create") + require.Contains(t, got.Suggestion, "Azure AI User") + require.Contains(t, got.Suggestion, shellSafePlaceholderID, + "redacted mode must substitute the shell-safe placeholder for the principal id") + require.Contains(t, got.Suggestion, shellSafePlaceholderScope, + "redacted mode must substitute the shell-safe placeholder for the scope") + require.NotContains(t, got.Suggestion, redactedPlaceholder, + "the `` token must NOT appear in the templated az command "+ + "(it triggers shell redirection on copy-paste)") + require.NotContains(t, got.Suggestion, "principal-oid", + "raw principal id must not leak in redacted suggestion") + require.NotContains(t, got.Suggestion, "/subscriptions/sub", + "raw scope must not leak in redacted suggestion") + require.NotEmpty(t, got.Links, + "Fail must carry a learn.microsoft.com link for the role-assignment guide") +} + +func TestClassifyRBACResult_FailsAndIncludesIdentitiesWhenUnredacted(t *testing.T) { + t.Parallel() + + scope := "/subscriptions/sub/rg/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj" + got := classifyRBACResult(&project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "Alice Example", + HasSufficientAIRole: false, + ProjectScope: scope, + AccountName: "acct", + ProjectName: "proj", + }, true /* unredacted */) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Message, "Alice Example", + "unredacted mode must include the real display name in the Message") + require.Contains(t, got.Suggestion, "principal-oid", + "unredacted mode must include the real principal id") + require.Contains(t, got.Suggestion, "/subscriptions/sub", + "unredacted mode must include the real scope") + require.NotContains(t, got.Suggestion, shellSafePlaceholderID, + "unredacted mode must not substitute placeholder for the principal id") + require.NotContains(t, got.Suggestion, shellSafePlaceholderScope, + "unredacted mode must not substitute placeholder for the scope") +} + +func TestClassifyRBACResult_FallsBackToGenericDisplayWhenMissing(t *testing.T) { + t.Parallel() + + // Both with-display-redacted and missing-display paths should + // converge on the same redactedDisplayLabel for a uniform UX. + got := classifyRBACResult(&project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "", // Graph didn't return a display name + HasSufficientAIRole: true, + AccountName: "acct", + ProjectName: "proj", + ProjectScope: "/x", + }, true /* unredacted: empty-display fallback still applies */) + + require.Contains(t, got.Message, redactedDisplayLabel, + "missing display name must fall back to the generic label "+ + "even when unredacted is true") +} + +// ---- Probe-error branches ---- + +func TestCheckRBAC_SkipsOnCancellationDuringProbe(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return nil, context.Canceled + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status, + "cancellation must propagate as a clean Skip, not a Fail") + require.Contains(t, got.Message, "cancelled") +} + +func TestCheckRBAC_SkipsOnTransientProbeError(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return nil, errors.New("dial tcp: i/o timeout\nsecond line should be stripped") + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status, + "transient probe error must Skip (not Fail) to avoid false alarms") + require.Contains(t, got.Message, "could not query role assignments") + require.Contains(t, got.Message, "dial tcp: i/o timeout") + require.NotContains(t, got.Message, "second line should be stripped", + "firstLine helper must strip subsequent lines from the error") + require.NotContains(t, got.Details, "probeError", + "default redacted mode must omit the raw probe error from Details") +} + +// TestCheckRBAC_SkipsWhenProjectIDMalformed exercises the upfront +// ValidateProjectResourceID gate added in response to Sonnet 4.6's +// review of commit 0c4d5ee31. Without this gate, a malformed +// AZURE_AI_PROJECT_ID (e.g., a URL instead of an ARM resource ID) +// would propagate through parseAgentIdentityInfo inside +// QueryDeveloperRBAC and surface as a generic "check your network" +// Suggestion — actively misleading the user toward network debugging +// for what is a pure configuration error. +func TestCheckRBAC_SkipsWhenProjectIDMalformed(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "https://example.com/not-an-arm-resource-id", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + t.Fatal("probe must not be invoked when projectID fails the upfront validation") + return nil, nil + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "is not a valid Foundry project ARM resource ID") + require.Contains(t, got.Suggestion, "azd env set AZURE_AI_PROJECT_ID") + require.NotContains(t, got.Suggestion, "graph.microsoft.com", + "malformed-ID Skip must NOT surface the network-retry Suggestion") + require.NotContains(t, got.Details, "validateError", + "default redacted mode must omit the raw validate error from Details") +} + +// TestCheckRBAC_SkipsWhenProjectIDMalformed_UnredactedSurfacesError +// pins that --unredacted exposes the raw validation error so +// interactive users get the precise reason the resource ID failed +// the shape check. +func TestCheckRBAC_SkipsWhenProjectIDMalformed_UnredactedSurfacesError(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "https://example.com/not-an-arm-resource-id", nil + }, + }) + + got := check.Fn(t.Context(), Options{Unredacted: true}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.NotNil(t, got.Details["validateError"], + "unredacted mode must expose the raw validation error in Details") +} + +// TestCheckRBAC_SkipsOnSPNToken pins the project.ErrSPNDelegatedAuthRequired +// branch added in response to GPT-5.5's review finding. Without this +// branch, users signed in via `azd auth login --client-id ...` +// (service-principal flow) get a confusing "check your network" +// Suggestion when the underlying Graph /me call rejects the app-only +// token. The targeted SPN Skip tells them to switch to user-delegated +// sign-in. +func TestCheckRBAC_SkipsOnSPNToken(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return nil, fmt.Errorf("%w: graph response: %s", + project.ErrSPNDelegatedAuthRequired, + "/me request is only valid with delegated authentication flow") + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "user-delegated", + "SPN Skip message must reference user-delegated sign-in") + require.Contains(t, got.Suggestion, "azd auth login", + "SPN Skip Suggestion must point at user-delegated sign-in") +} + +// TestCheckRBAC_SkipsOnInvalidProjectIDSentinelFromProbe pins the +// defensive ErrInvalidProjectResourceID branch in +// classifyRBACProbeError. The upfront ValidateProjectResourceID +// gate catches this normally, but a future code path that bypasses +// the gate (or returns a wrapped sentinel from somewhere inside the +// probe stack) must still surface the configuration-error Suggestion. +func TestCheckRBAC_SkipsOnInvalidProjectIDSentinelFromProbe(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + // Return a VALID ID so the upfront validation passes + // and the probe seam is reached. The probe then returns + // a wrapped ErrInvalidProjectResourceID to exercise the + // defensive branch. + return "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return nil, fmt.Errorf("%w: simulated inner parse failure", + project.ErrInvalidProjectResourceID) + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "is not a valid Foundry project ARM resource ID") + require.Contains(t, got.Suggestion, "azd env set", + "defensive sentinel branch must surface the configuration Suggestion") + require.NotContains(t, got.Suggestion, "graph.microsoft.com", + "defensive sentinel branch must NOT surface the network-retry Suggestion") +} + +// TestCheckRBAC_TransientProbeErrorScrubsScopeARNs pins the scope- +// redaction added in response to Opus xhigh + GPT-5.5's finding that +// azcore.ResponseError.Error() puts the full ARM URL (with +// subscription / resource group / account) on the first line, which +// would otherwise leak past the doctor's redaction model. +func TestCheckRBAC_TransientProbeErrorScrubsScopeARNs(t *testing.T) { + t.Parallel() + + leakyErr := "GET https://management.azure.com/subscriptions/" + + "11111111-1111-1111-1111-111111111111/resourceGroups/" + + "super-secret-rg/providers/Microsoft.CognitiveServices/accounts/" + + "super-secret-acct/providers/Microsoft.Authorization/roleAssignments " + + "-> RESPONSE 500: transient ARM failure" + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/11111111-1111-1111-1111-111111111111/" + + "resourceGroups/super-secret-rg/providers/" + + "Microsoft.CognitiveServices/accounts/super-secret-acct/" + + "projects/super-secret-proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return nil, errors.New(leakyErr) + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.NotContains(t, got.Message, "11111111-1111-1111-1111-111111111111", + "subscription ID GUID must be redacted out of the probe error Message") + require.NotContains(t, got.Message, "super-secret-rg", + "resource group name must be redacted out of the probe error Message") + require.NotContains(t, got.Message, "super-secret-acct", + "account name must be redacted out of the probe error Message") + require.NotContains(t, got.Message, "/subscriptions/", + "raw `/subscriptions/...` path must be redacted out of the Message") +} + +// TestCheckRBAC_TransientProbeErrorPreservesScopesWhenUnredacted +// pins that --unredacted does NOT scrub the probe error, so +// interactive users can still see the raw URL for debugging. +func TestCheckRBAC_TransientProbeErrorPreservesScopesWhenUnredacted(t *testing.T) { + t.Parallel() + + leakyErr := "GET https://management.azure.com/subscriptions/" + + "11111111-1111-1111-1111-111111111111/resourceGroups/rg " + + "-> RESPONSE 500" + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/11111111-1111-1111-1111-111111111111/" + + "resourceGroups/rg/providers/Microsoft.CognitiveServices/" + + "accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return nil, errors.New(leakyErr) + }, + }) + + got := check.Fn(t.Context(), Options{Unredacted: true}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "11111111-1111-1111-1111-111111111111", + "unredacted mode must preserve the raw subscription ID in the Message") + require.Equal(t, leakyErr, got.Details["probeError"], + "unredacted mode must surface the raw probe error in Details") +} + +func TestCheckRBAC_SkipsWhenProjectIDReaderErrors(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "", errors.New("rpc error: code = Unavailable desc = connection closed") + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + t.Fatal("probe must not be invoked when readProjectResourceID errors") + return nil, nil + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "could not read AZURE_AI_PROJECT_ID") + require.Contains(t, got.Suggestion, "azd provision") + require.Contains(t, got.Suggestion, "azd env set AZURE_AI_PROJECT_ID") +} + +func TestCheckRBAC_SkipsWhenProjectIDEmpty(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + t.Fatal("probe must not be invoked when AZURE_AI_PROJECT_ID is unset") + return nil, nil + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusSkip, got.Status) + require.Contains(t, got.Message, "AZURE_AI_PROJECT_ID is not set") + require.Contains(t, got.Suggestion, "azd provision") +} + +// ---- End-to-end probe-injection: Pass / Fail wired through the check ---- + +func TestCheckRBAC_PassesWhenProbeReturnsRole(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return &project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "Alice Example", + HasSufficientAIRole: true, + ProjectScope: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", + AccountName: "acct", + ProjectName: "proj", + }, nil + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusPass, got.Status) + require.Contains(t, got.Message, redactedDisplayLabel, + "default redacted mode must use the generic display label, not the raw display name") + require.NotContains(t, got.Message, "Alice Example", + "raw PrincipalDisplay must not leak through the check Fn in default mode") + require.Contains(t, got.Message, "acct/proj") +} + +func TestCheckRBAC_FailsWhenProbeReturnsNoRole(t *testing.T) { + t.Parallel() + + check := newCheckRBAC(Dependencies{ + AzdClient: &azdext.AzdClient{}, + readProjectResourceIDFn: func(_ context.Context, _ *azdext.AzdClient) (string, error) { + return "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", nil + }, + probeDeveloperRBAC: func(_ context.Context, _ *azdext.AzdClient, _ string) (*project.DeveloperRBACResult, error) { + return &project.DeveloperRBACResult{ + PrincipalID: "principal-oid", + PrincipalDisplay: "Alice Example", + HasSufficientAIRole: false, + ProjectScope: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", + AccountName: "acct", + ProjectName: "proj", + }, nil + }, + }) + + got := check.Fn(t.Context(), Options{}, passingPriorsForRBAC()) + + require.Equal(t, StatusFail, got.Status) + require.Contains(t, got.Suggestion, "az role assignment create") + require.NotEmpty(t, got.Links) + require.Equal(t, rbacLearnLink, got.Links[0]) +} + +// ---- sanitizeScopeARNs ---- + +// TestSanitizeScopeARNs pins the regex-based scope + GUID scrubber +// used in the probe-error path. Covers the leak vectors enumerated +// in the Opus xhigh + GPT-5.5 reviews of commit 0c4d5ee31: +// - Full ARM URL from azcore.ResponseError.Error() +// - Bare ARM resource ID embedded in prose +// - Subscription-only scope (/subscriptions/) +// - Bare GUID outside any scope path +// - Mixed text with multiple scopes +func TestSanitizeScopeARNs(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + out string + }{ + { + name: "full ARM URL from azcore ResponseError", + in: "GET https://management.azure.com/subscriptions/" + + "11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Authorization/roleAssignments -> 500", + out: "GET https://management.azure.com -> 500", + }, + { + name: "bare ARM resource ID in prose", + in: "could not list role assignments at scope " + + "/subscriptions/abc/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct: 403", + out: "could not list role assignments at scope : 403", + }, + { + name: "subscription-only scope", + in: "denied at /subscriptions/abc", + out: "denied at ", + }, + { + name: "bare GUID outside a scope path", + in: "principal 22222222-2222-2222-2222-222222222222 " + + "does not have access", + out: "principal does not have access", + }, + { + name: "no sensitive substrings - pass-through", + in: "dial tcp: i/o timeout", + out: "dial tcp: i/o timeout", + }, + { + name: "idempotent on already-redacted text", + in: "denied at ", + out: "denied at ", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.out, sanitizeScopeARNs(tc.in)) + }) + } +} + +// ---- redaction helpers ---- + +func TestRedactHelpers_RedactWhenFlagOff(t *testing.T) { + t.Parallel() + + require.Equal(t, redactedPlaceholder, redactID("oid-123", false)) + require.Equal(t, redactedPlaceholder, redactScope("/subscriptions/sub", false)) + require.Equal(t, redactedPlaceholder, redactDisplay("Alice Example", false)) +} + +func TestRedactHelpers_PassthroughWhenFlagOn(t *testing.T) { + t.Parallel() + + require.Equal(t, "oid-123", redactID("oid-123", true)) + require.Equal(t, "/subscriptions/sub", redactScope("/subscriptions/sub", true)) + require.Equal(t, "Alice Example", redactDisplay("Alice Example", true)) +} + +func TestRedactHelpers_EmptyInputIsAlwaysEmpty(t *testing.T) { + t.Parallel() + + // An empty input represents a missing field (Graph didn't + // return it); the helper should NOT substitute the placeholder + // there because that would falsely imply a value was present. + require.Empty(t, redactID("", false)) + require.Empty(t, redactScope("", false)) + require.Empty(t, redactDisplay("", false)) + require.Empty(t, redactID("", true)) + require.Empty(t, redactScope("", true)) + require.Empty(t, redactDisplay("", true)) +} + +// ---- Sanity check: token / OID must not leak through the Suggestion +// when the placeholder substitution path is exercised ---- + +func TestClassifyRBACResult_RedactedSuggestionDoesNotLeakIdentifiers(t *testing.T) { + t.Parallel() + + got := classifyRBACResult(&project.DeveloperRBACResult{ + PrincipalID: "extremely-secret-oid-1234567890", + PrincipalDisplay: "Alice Example", + HasSufficientAIRole: false, + ProjectScope: "/subscriptions/super-secret-sub/rg/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", + AccountName: "acct", + ProjectName: "proj", + }, false) + + require.NotContains(t, got.Suggestion, "extremely-secret-oid-1234567890") + require.NotContains(t, got.Suggestion, "super-secret-sub") + // The Message renders short-form identifiers ("acct/proj") so + // the account/project names ARE allowed — they are part of the + // human-readable summary, not sensitive scope ARNs. But the + // raw scope must not leak. + require.NotContains(t, got.Message, "super-secret-sub") +} + +// ---- Default-wiring sanity ---- +// +// The two production defaults (readProjectResourceID and +// project.QueryDeveloperRBAC) are wired via a `nil`-then-fallback +// pattern inside newCheckRBAC. They depend on a live gRPC channel +// and real ARM/Graph stacks; a unit-test panic-safe driver would +// require a substantial fake-client harness that this package does +// not yet provide. The fallback wiring is verified by code review +// (single-line `if probe == nil { probe = project.QueryDeveloperRBAC }`) +// and by `TestCheckRBAC_PassesWhenProbeReturnsRole` / `TestCheckRBAC_FailsWhenProbeReturnsNoRole` +// exercising the surrounding flow with the seams in place. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote.go new file mode 100644 index 00000000000..821ae74e4d8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote.go @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +// NewRemoteChecks returns the canonical sequence of remote (network- +// dependent) doctor checks in execution order. The slice today +// contains four entries — `remote.auth` (P5.1 C11), +// `remote.foundry-endpoint` (P5.1 C12), `remote.rbac` (P5.1 C16), +// and `remote.agent-status` (P5.1 C17) — and is wired through +// `--local-only`, the runner's `Remote: true` gating +// (runner.go:74-82), and `report.Remote` (set when any executed +// check is Remote) so that downstream commits can append individual +// checks without touching the doctor command's Cobra wiring. +// +// # Conventions for remote checks added in C11+ +// +// - Set Remote: true on the Check value. The runner uses this both +// to skip the check under --local-only and to flip +// report.Remote = true when the check runs (used by the JSON +// envelope and the formatter's "remote checks were exercised" +// decisions). +// - Forward the `Dependencies` struct to each check's closure. C11+ +// checks that require auth credentials or a REST client should +// add those fields to `Dependencies` (defined in checks_local.go) +// and document them there. Tests inject fakes via the same fields +// that production wiring populates from the Cobra surface. +// - Skip-cascade against the local chain. Most remote checks +// require at least: +// - `local.grpc-extension` to have produced an AzdClient +// - `local.azure-yaml` for the project root +// - `local.environment-selected` for the active azd env name +// - `local.project-endpoint-set` for AZURE_AI_PROJECT_ENDPOINT +// Guard with one or more `priorBlocked(prior, "")` calls and +// return Result{Status: StatusSkip, Message: "..."}. Doing the +// work inside the check (rather than in the runner) keeps the +// skip-message specific to the inherited failure so users see a +// pointed suggestion instead of a generic "upstream check failed". +// - Honor ctx cancellation. Remote checks own a network round trip; +// the runner only checks ctx.Err between checks, so a long-blocked +// HTTP call would otherwise stall a Ctrl-C. +// - When Unredacted is false (the default), elide raw principal IDs +// / scope ARNs / UPNs from the Message. The full payload still +// goes into Details for callers that opt in via --unredacted. +// +// # Ordering relative to local checks +// +// In `doctor.go:runDoctor`, remote checks are appended AFTER all +// local checks. This is deliberate: every remote check's skip-cascade +// reads `prior []Result`, and the local results must be available in +// that slice when the remote check runs. The runner's loop preserves +// the order of `Runner.Checks`, so appending remote-after-local is +// sufficient. +func NewRemoteChecks(deps Dependencies) []Check { + // Phase 5 commits append entries here: + // - C11 (landed): auth probe (`remote.auth`) + // - C12 (landed): foundry project endpoint reachability + // (`remote.foundry-endpoint`) + // - C16 (landed): developer RBAC on the Foundry project + // (`remote.rbac`) + // - C17 (landed): per-service agent version status + // (`remote.agent-status`) + // - C12 (landed): per-agent managed-identity role + // listing across project/account/RG scopes + // (`remote.agent-identity-roles`) + // - C13 (landed): manifest model deployments exist on + // the Foundry project's Cognitive Services account + // (`remote.model-deployments`) + // - C15 (this commit): manifest connections exist on the + // Foundry project (`remote.connections`) + // Ordering matters for skip-cascade: each entry reads `prior + // []Result` produced by every check earlier in the combined + // local-then-remote sequence. Append checks in the order their + // preconditions resolve so a downstream check can short-circuit + // when an upstream check fails. + return []Check{ + newCheckAuth(deps), + newCheckFoundryEndpoint(deps), + newCheckRBAC(deps), + newCheckAgentStatus(deps), + newCheckAgentIdentityRoles(deps), + newCheckModelDeployments(deps), + newCheckConnections(deps), + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote_test.go new file mode 100644 index 00000000000..0f97d03dbe2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote_test.go @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// ---- NewRemoteChecks contract ---- + +// TestNewRemoteChecks_HasAuthFoundryEndpointRBACAgentStatusIdentityRolesModelDeploymentsConnections +// pins the current shape of the remote chain: exactly seven checks, +// in the order `remote.auth` → `remote.foundry-endpoint` → +// `remote.rbac` → `remote.agent-status` → `remote.agent-identity-roles` +// → `remote.model-deployments` → `remote.connections`, all with +// Remote=true. The ordering matters because `remote.foundry-endpoint` +// skip-cascades against `remote.auth`'s prior Result, `remote.rbac` +// skip-cascades against `remote.auth` (but NOT +// `remote.foundry-endpoint`, per the design's dependency matrix line +// 115 — RBAC reads ARM, not the data plane), `remote.agent-status` +// skip-cascades against `remote.auth` + `remote.foundry-endpoint` +// (Reader-level Foundry call, deliberately bypasses RBAC), +// `remote.agent-identity-roles` cascades against `remote.agent-status` +// Pass so the per-agent role enumeration only runs against agents the +// previous check confirmed active, `remote.model-deployments` +// cascades against `remote.auth` + `remote.foundry-endpoint` because +// it issues an ARM-side Cognitive Services deployments list using the +// same identity, and `remote.connections` cascades against the same +// pair plus `state.HasConnections` because it lists Foundry +// connections via the data plane. Any future re-ordering or +// insertion has to come through this assertion. +func TestNewRemoteChecks_HasAuthFoundryEndpointRBACAgentStatusIdentityRolesModelDeploymentsConnections(t *testing.T) { + t.Parallel() + + got := NewRemoteChecks(Dependencies{}) + + require.Len(t, got, 7, + "NewRemoteChecks should contain auth, foundry-endpoint, rbac, agent-status, "+ + "agent-identity-roles, model-deployments, and connections today") + require.Equal(t, "remote.auth", got[0].ID) + require.Equal(t, "authentication", got[0].Name) + require.True(t, got[0].Remote, "remote.auth must declare Remote=true") + require.NotNil(t, got[0].Fn, "remote.auth must have a non-nil Fn") + require.Equal(t, "remote.foundry-endpoint", got[1].ID) + require.Equal(t, "Foundry project endpoint reachable", got[1].Name) + require.True(t, got[1].Remote, "remote.foundry-endpoint must declare Remote=true") + require.NotNil(t, got[1].Fn, "remote.foundry-endpoint must have a non-nil Fn") + require.Equal(t, "remote.rbac", got[2].ID) + require.Equal(t, "Developer has required role on Foundry project", got[2].Name) + require.True(t, got[2].Remote, "remote.rbac must declare Remote=true") + require.NotNil(t, got[2].Fn, "remote.rbac must have a non-nil Fn") + require.Equal(t, "remote.agent-status", got[3].ID) + require.Equal(t, "Hosted agents are active", got[3].Name) + require.True(t, got[3].Remote, "remote.agent-status must declare Remote=true") + require.NotNil(t, got[3].Fn, "remote.agent-status must have a non-nil Fn") + require.Equal(t, "remote.agent-identity-roles", got[4].ID) + require.Equal(t, "Agent identity role assignments", got[4].Name) + require.True(t, got[4].Remote, "remote.agent-identity-roles must declare Remote=true") + require.NotNil(t, got[4].Fn, "remote.agent-identity-roles must have a non-nil Fn") + require.Equal(t, "remote.model-deployments", got[5].ID) + require.Equal(t, "Manifest model deployments exist in Foundry", got[5].Name) + require.True(t, got[5].Remote, "remote.model-deployments must declare Remote=true") + require.NotNil(t, got[5].Fn, "remote.model-deployments must have a non-nil Fn") + require.Equal(t, "remote.connections", got[6].ID) + require.Equal(t, "Manifest connections exist on Foundry project", got[6].Name) + require.True(t, got[6].Remote, "remote.connections must declare Remote=true") + require.NotNil(t, got[6].Fn, "remote.connections must have a non-nil Fn") +} + +// TestNewLocalAndRemoteChecks_ProductionCompositionLocalsFirst pins the +// load-bearing local-then-remote ordering that doctor.go:runDoctor +// composes via `append(NewLocalChecks, NewRemoteChecks...)`. Without +// this test, a future contributor could accidentally swap the +// composition order (or land a remote check inside NewLocalChecks / +// vice versa) and every other existing test would still pass, while +// remote checks' `priorBlocked(prior, "local.X")` skip-cascade guards +// would silently always return false. +// +// We assert two invariants on the production composition: +// +// 1. No local check (Remote == false) appears AFTER any remote check. +// Locals must run first so their results are in `prior` when remote +// checks evaluate `priorBlocked`. +// 2. Every check returned by NewRemoteChecks carries Remote == true +// (the same convention bullet documented in checks_remote.go). +// Forgetting the flag would cause the runner to (a) not skip the +// check under --local-only and (b) not flip report.Remote. +func TestNewLocalAndRemoteChecks_ProductionCompositionLocalsFirst(t *testing.T) { + t.Parallel() + + locals := NewLocalChecks(Dependencies{}) + remotes := NewRemoteChecks(Dependencies{}) + + for i, c := range locals { + require.Falsef(t, c.Remote, + "NewLocalChecks[%d] %q has Remote=true; locals must declare Remote=false", + i, c.ID) + } + for i, c := range remotes { + require.Truef(t, c.Remote, + "NewRemoteChecks[%d] %q has Remote=false; remotes must declare Remote=true", + i, c.ID) + } + + // Invariant 1: combined ordering must place every local before + // every remote. Equivalent to the contract `runDoctor` relies on. + combined := append(locals, remotes...) + sawRemote := false + for _, c := range combined { + if c.Remote { + sawRemote = true + continue + } + require.Falsef(t, sawRemote, + "local check %q appears after a remote check in the "+ + "combined doctor pipeline; runDoctor's skip-cascade "+ + "contract requires local-then-remote ordering", + c.ID) + } +} + +// ---- Framework integration: local + remote interaction ---- + +// TestRunner_LocalThenRemote_RemoteSeesLocalPriorResults proves the +// runner preserves the order `NewLocalChecks ++ NewRemoteChecks` so a +// remote check's skip-cascade can read the local check results. This +// is the load-bearing contract C11+ remote checks depend on (each one +// calls `priorBlocked(prior, "local.X")` to decide whether to skip). +// +// We don't use the real NewLocalChecks here because that would couple +// this test to the live gRPC stack. Instead we synthesize a local + +// remote pair using the same Check shape and assert the ordering. +func TestRunner_LocalThenRemote_RemoteSeesLocalPriorResults(t *testing.T) { + t.Parallel() + + var observed []Result + runner := &Runner{ + Checks: append( + []Check{ + {ID: "local.x", Name: "local x", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusFail, Message: "local x failed"} + }}, + }, + Check{ + ID: "remote.y", + Name: "remote y", + Remote: true, + Fn: func(_ context.Context, _ Options, prior []Result) Result { + observed = append([]Result(nil), prior...) + // Mirror the convention C11+ checks will follow: + // inspect prior, skip when a local precondition + // failed. + if priorBlocked(prior, "local.x") { + return Result{Status: StatusSkip, Message: "skipped: upstream local.x"} + } + return Result{Status: StatusPass, Message: "remote y ran"} + }, + }, + ), + } + + report := runner.Run(t.Context(), Options{}) + + require.Len(t, observed, 1, "remote check must see exactly the one local prior result") + require.Equal(t, "local.x", observed[0].ID) + require.Equal(t, StatusFail, observed[0].Status) + require.Equal(t, StatusSkip, report.Checks[1].Status, "remote check should have skipped via priorBlocked") + require.Contains(t, report.Checks[1].Message, "upstream local.x") +} + +// TestRunner_LocalOnly_RemoteCheckNotInvoked complements the runner's +// existing TestRunner_Run_LocalOnly_SkipsRemoteChecks by exercising the +// combination used by the doctor command in production: +// `append(NewLocalChecks, NewRemoteChecks...)`. We synthesize a remote +// check that would Fail if invoked, then assert it produces a Skip +// without running. +func TestRunner_LocalOnly_AppendedRemoteCheck_NotInvoked(t *testing.T) { + t.Parallel() + + invoked := false + checks := append( + []Check{ + {ID: "local.x", Name: "local x", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "ok"} + }}, + }, + Check{ + ID: "remote.y", Name: "remote y", Remote: true, + Fn: func(_ context.Context, _ Options, _ []Result) Result { + invoked = true + return Result{Status: StatusFail, Message: "remote check ran when it should not have"} + }, + }, + ) + + runner := &Runner{Checks: checks} + report := runner.Run(t.Context(), Options{LocalOnly: true}) + + require.False(t, invoked, "remote check function must not be invoked under --local-only") + require.Len(t, report.Checks, 2) + require.Equal(t, StatusPass, report.Checks[0].Status) + require.Equal(t, StatusSkip, report.Checks[1].Status) + require.Contains(t, report.Checks[1].Message, "local-only") + require.False(t, report.Remote, "report.Remote must remain false when only local checks executed") +} + +// TestRunner_RemoteCheck_RanProducesReportRemoteFlag mirrors the +// existing TestRunner_Run_RemoteCheck_FlipsReportRemoteFlag but +// scoped to the combined local+remote shape used in production. +func TestRunner_RemoteCheck_RanProducesReportRemoteFlag(t *testing.T) { + t.Parallel() + + checks := append( + []Check{ + {ID: "local.x", Name: "local x", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass} + }}, + }, + Check{ + ID: "remote.y", Name: "remote y", Remote: true, + Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass} + }, + }, + ) + + report := (&Runner{Checks: checks}).Run(t.Context(), Options{}) + + require.True(t, report.Remote, "any executed remote check must flip report.Remote") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_toolboxes.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_toolboxes.go new file mode 100644 index 00000000000..9e41dcc0c4c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_toolboxes.go @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "fmt" + "slices" + "sort" + "strings" + + "azureaiagent/internal/cmd/nextstep" + "azureaiagent/internal/pkg/envkey" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// toolboxEnvLookupFn is the seam-friendly signature for reading one +// env var from the active azd environment. The Doctor's existing +// project-endpoint and rbac checks read AZURE_AI_PROJECT_* directly +// via gRPC; this check reads N values (one per toolbox), so isolating +// the call shape behind a closure simplifies test fakes. Implementations +// may return ("", nil) for an unset key (matches the azd gRPC env +// service's actual contract). +type toolboxEnvLookupFn func(ctx context.Context, key string) (value string, err error) + +// newCheckToolboxes produces Check `local.toolboxes` (P5.1 C14). +// For each `ToolboxResource` declared in any service's +// `agent.manifest.yaml` (collected by the C2 manifest walker), the +// check verifies that the canonical +// `TOOLBOX__MCP_ENDPOINT` env var is set to a +// non-empty value in the active azd environment. +// +// The check is classified `local` (Remote: false) because it only +// reads the active azd environment — no ARM / Foundry round trips. +// `--local-only` therefore still runs it. +// +// # Skip cascade +// +// - deps.AzdClient nil → upstream `local.grpc-extension` failure. +// - `local.environment-selected` failed/skipped → there is no env +// to read from. AssembleState's detectMissingVars block also +// skips in this state, so the toolbox check would falsely Pass. +// - `local.azure-yaml` / `local.agent-service-detected` failed → +// no services to walk; walker output is unreliable. +// - state.HasToolboxes == false → no manifest toolbox declarations; +// the check has nothing to verify. +// +// # Why this check is not gated on `remote.auth` / +// `remote.foundry-endpoint` +// +// Unlike `remote.model-deployments`, this check does NOT talk to ARM +// or Foundry; it only reads local azd env state. Gating on remote +// upstream checks would surface a false Skip in the (legitimate) case +// where ARM is down but the user can still diagnose a missing local +// env var. +// +// # Classification +// +// - All toolboxes have a set endpoint → Pass. +// - One or more missing endpoints → Fail with the missing toolbox +// names in the Message, and `Details["missingToolboxes"]` listing +// each missing toolbox together with the env var name the check +// was expecting. +// - Env service transport error → Fail (NOT Skip): a Skip would +// leave the user with no actionable signal at all; the +// Suggestion points at the env service / azd config as the +// likely culprit. +func newCheckToolboxes(deps Dependencies) Check { + return Check{ + ID: "local.toolboxes", + Name: "Manifest toolboxes have endpoint env vars set", + Remote: false, + Fn: func(ctx context.Context, _ Options, prior []Result) Result { + if deps.AzdClient == nil { + return Result{ + Status: StatusSkip, + Message: "skipped: azd extension not reachable.", + } + } + if priorBlocked(prior, "local.environment-selected") { + return Result{ + Status: StatusSkip, + Message: "skipped: no azd environment is selected " + + "(see check `local.environment-selected`).", + } + } + if priorBlocked(prior, "local.azure-yaml") || + priorBlocked(prior, "local.agent-service-detected") { + return Result{ + Status: StatusSkip, + Message: "skipped: azure.yaml / agent service detection failed " + + "(see checks `local.azure-yaml`, `local.agent-service-detected`).", + } + } + + assembler := deps.assembleState + if assembler == nil { + assembler = func(c context.Context, client *azdext.AzdClient) (*nextstep.State, []error) { + return nextstep.AssembleState(c, client) + } + } + state, errs := assembler(ctx, deps.AzdClient) + if state == nil { + // AssembleState always returns a non-nil State even when errs + // is non-empty (state.go), but defend against a future contract + // change so the check surfaces the real cause instead of a + // misleading "no toolboxes declared" Skip. + cause := "unknown error" + if len(errs) > 0 { + cause = errs[0].Error() + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("failed to assemble agent state: %s", cause), + Suggestion: "Re-run `azd ai agent doctor`; the state assembly returned nil unexpectedly.", + } + } + if !state.HasToolboxes { + return Result{ + Status: StatusSkip, + Message: "skipped: no toolbox resources declared in any service's agent.manifest.yaml.", + } + } + + lookup := deps.lookupToolboxEnv + if lookup == nil { + lookup = makeRealToolboxEnvLookup(deps.AzdClient) + } + + return classifyToolboxEndpoints(ctx, state.Toolboxes, lookup) + }, + } +} + +// normalizeToolboxName / toolboxEndpointKey have been replaced by the +// shared `internal/pkg/envkey` package. See envkey.ToolboxMCPEndpoint. + +// classifyToolboxEndpoints joins state.Toolboxes to the active azd +// env. Each toolbox produces one env lookup; the first transport +// error short-circuits the check to Fail (NOT Skip — see the +// factory's doc-comment for why) so the user gets one actionable +// surface instead of a quiet pass-through. +// +// Dedup is on the canonical env key, not the toolbox name: the +// manifest walker deduplicates on (ServiceName, Name) so the same toolbox +// referenced by two services surfaces twice in state.Toolboxes. +// Without dedup here the doctor would issue two gRPC reads for the +// same key and report the same toolbox twice in the missing list. +func classifyToolboxEndpoints( + ctx context.Context, + toolboxes []nextstep.ResourceRef, + lookup toolboxEnvLookupFn, +) Result { + type toolboxLookup struct { + Name string `json:"name"` + ServiceName string `json:"service"` + EnvVar string `json:"envVar"` + } + + seen := make(map[string]struct{}, len(toolboxes)) + var missing []toolboxLookup + matched := 0 + + for _, t := range toolboxes { + key := envkey.ToolboxMCPEndpoint(t.Name) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + + value, err := lookup(ctx, key) + if err != nil { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "could not read toolbox endpoint env vars from the azd environment: %s", + err), + Suggestion: "Verify the azd extension is healthy and the active environment is accessible. " + + "Try `azd env list` and `azd env get-values`.", + } + } + if strings.TrimSpace(value) == "" { + missing = append(missing, toolboxLookup{ + Name: t.Name, ServiceName: t.ServiceName, EnvVar: key, + }) + continue + } + matched++ + } + + sort.Slice(missing, func(i, j int) bool { + if missing[i].Name != missing[j].Name { + return missing[i].Name < missing[j].Name + } + return missing[i].ServiceName < missing[j].ServiceName + }) + + if len(missing) == 0 { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("all %d declared toolbox(es) have an MCP endpoint set.", matched), + Details: map[string]any{ + "matchedCount": matched, + }, + } + } + + var sb strings.Builder + for i, m := range missing { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s (env %s, service %s)", m.Name, m.EnvVar, m.ServiceName)) + } + + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "%d toolbox(es) declared in agent.manifest.yaml have no MCP endpoint set in the azd environment: %s", + len(missing), sb.String()), + Suggestion: "Run `azd provision` to materialize toolbox infrastructure, or " + + "`azd env set ` to point at an existing toolbox.", + Details: map[string]any{ + "missingToolboxes": missing, + "matchedCount": matched, + }, + } +} + +// makeRealToolboxEnvLookup binds an `azdext.AzdClient` to a one-key +// env reader. The active environment is resolved by the gRPC server +// (caller does not need to know its name), matching the existing +// `readProjectResourceID` pattern in `checks_rbac.go:388-396`. +// +// An empty `Key` argument is treated as a programmer error and +// short-circuits with the rpc error rather than masking it. A +// missing key returns ("", nil) — the same shape every other azd +// extension expects from `GetValue`. +func makeRealToolboxEnvLookup(client *azdext.AzdClient) toolboxEnvLookupFn { + return func(ctx context.Context, key string) (string, error) { + resp, err := client.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + Key: key, + }) + if err != nil { + return "", err + } + return resp.Value, nil + } +} + +// dedupToolboxKeys returns the slice of canonical env keys the +// classifier would probe for a given ToolboxRef slice — exposed for +// the renderer / future telemetry consumer that wants to log "we +// expected these N env vars". The classifier does its own dedup +// inline; this helper is for callers that need the list up front. +func dedupToolboxKeys(toolboxes []nextstep.ResourceRef) []string { + seen := make(map[string]struct{}, len(toolboxes)) + keys := make([]string, 0, len(toolboxes)) + for _, t := range toolboxes { + key := envkey.ToolboxMCPEndpoint(t.Name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_toolboxes_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_toolboxes_test.go new file mode 100644 index 00000000000..14f7fa91b2a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_toolboxes_test.go @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// fixedToolboxLookup returns a toolboxEnvLookupFn that resolves a +// canned (value, ok) map keyed by canonical env var name. Unknown +// keys return ("", nil) — matching the azd env-service contract for +// missing keys (see makeRealToolboxEnvLookup's doc comment). +func fixedToolboxLookup(values map[string]string) toolboxEnvLookupFn { + return func(_ context.Context, key string) (string, error) { + return values[key], nil + } +} + +func runToolboxesCheck(t *testing.T, deps Dependencies, prior []Result) Result { + t.Helper() + if deps.AzdClient == nil { + deps.AzdClient = &azdext.AzdClient{} + } + c := newCheckToolboxes(deps) + require.NotNil(t, c.Fn) + require.Equal(t, "local.toolboxes", c.ID) + require.False(t, c.Remote, "toolboxes check must be tagged local (Remote=false)") + return c.Fn(t.Context(), Options{}, prior) +} + +// stateWithToolboxes builds a *nextstep.State whose HasToolboxes flag +// is wired to match the supplied slice (mirrors what the C2 manifest +// walker would produce). +func stateWithToolboxes(refs ...nextstep.ResourceRef) *nextstep.State { + return &nextstep.State{ + HasToolboxes: len(refs) > 0, + Toolboxes: refs, + } +} + +// ---- Skip-cascade gates ---- + +func TestCheckToolboxes_SkipsWhenAzdClientNil(t *testing.T) { + t.Parallel() + c := newCheckToolboxes(Dependencies{}) + res := c.Fn(t.Context(), Options{}, nil) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "azd extension not reachable") +} + +func TestCheckToolboxes_SkipsCascadeFromUpstream(t *testing.T) { + t.Parallel() + tests := []struct { + name string + failedID string + }{ + {"environment selected blocked", "local.environment-selected"}, + {"azure.yaml blocked", "local.azure-yaml"}, + {"agent service detected blocked", "local.agent-service-detected"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + prior := []Result{{ID: tc.failedID, Status: StatusFail}} + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes(nextstep.ResourceRef{Name: "wst"})), + lookupToolboxEnv: fixedToolboxLookup(map[string]string{ + "TOOLBOX_WST_MCP_ENDPOINT": "https://example/mcp", + }), + } + res := runToolboxesCheck(t, deps, prior) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, tc.failedID) + }) + } +} + +// Toolboxes is NOT gated on remote.auth / remote.foundry-endpoint — +// it only reads local env state. A failed auth/foundry-endpoint must +// not poison the toolbox check. +func TestCheckToolboxes_NotGatedOnRemotePriors(t *testing.T) { + t.Parallel() + prior := []Result{ + {ID: "local.azure-yaml", Status: StatusPass}, + {ID: "local.environment-selected", Status: StatusPass}, + {ID: "local.agent-service-detected", Status: StatusPass}, + {ID: "remote.auth", Status: StatusFail}, + {ID: "remote.foundry-endpoint", Status: StatusFail}, + } + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes(nextstep.ResourceRef{Name: "wst"})), + lookupToolboxEnv: fixedToolboxLookup(map[string]string{ + "TOOLBOX_WST_MCP_ENDPOINT": "https://example/mcp", + }), + } + res := runToolboxesCheck(t, deps, prior) + require.Equal(t, StatusPass, res.Status) +} + +// ---- State emptiness ---- + +func TestCheckToolboxes_SkipsWhenNoToolboxesDeclared(t *testing.T) { + t.Parallel() + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(&nextstep.State{}), + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusSkip, res.Status) + require.Contains(t, res.Message, "no toolbox resources") +} + +func TestCheckToolboxes_FailsWhenAssemblerReturnsNilState(t *testing.T) { + t.Parallel() + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(nil), + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "failed to assemble agent state") +} + +func TestCheckToolboxes_FailSurfacesAssemblerErrCause(t *testing.T) { + t.Parallel() + cause := errors.New("manifest.walker: open agent.manifest.yaml: permission denied") + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: func(_ context.Context, _ *azdext.AzdClient) (*nextstep.State, []error) { + return nil, []error{cause} + }, + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "permission denied", + "first errs entry should be surfaced in the Fail message") +} + +// ---- Classification: all-present / partial / all-missing ---- + +func TestCheckToolboxes_PassesWhenAllEndpointsSet(t *testing.T) { + t.Parallel() + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes( + nextstep.ResourceRef{Name: "web-search-tools", ServiceName: "svc-a"}, + nextstep.ResourceRef{Name: "code-runner", ServiceName: "svc-b"}, + )), + lookupToolboxEnv: fixedToolboxLookup(map[string]string{ + "TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT": "https://wst.example/mcp", + "TOOLBOX_CODE_RUNNER_MCP_ENDPOINT": "https://cr.example/mcp", + }), + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusPass, res.Status) + require.Contains(t, res.Message, "2 declared toolbox(es)") + require.Equal(t, 2, res.Details["matchedCount"]) +} + +func TestCheckToolboxes_FailsWhenSomeEndpointsMissing(t *testing.T) { + t.Parallel() + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes( + nextstep.ResourceRef{Name: "web-search-tools", ServiceName: "svc-a"}, + nextstep.ResourceRef{Name: "code-runner", ServiceName: "svc-b"}, + )), + lookupToolboxEnv: fixedToolboxLookup(map[string]string{ + "TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT": "https://wst.example/mcp", + // code-runner missing + }), + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "code-runner") + require.Contains(t, res.Message, "TOOLBOX_CODE_RUNNER_MCP_ENDPOINT") + require.NotContains(t, res.Message, "web-search-tools") + require.Contains(t, res.Suggestion, "azd provision") + require.Equal(t, 1, res.Details["matchedCount"]) +} + +func TestCheckToolboxes_FailsWhenAllEndpointsMissing(t *testing.T) { + t.Parallel() + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes( + nextstep.ResourceRef{Name: "web-search-tools", ServiceName: "svc-a"}, + )), + lookupToolboxEnv: fixedToolboxLookup(map[string]string{}), + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusFail, res.Status) + require.Contains(t, res.Message, "1 toolbox(es)") + require.Equal(t, 0, res.Details["matchedCount"]) +} + +// Empty / whitespace-only values are treated as unset (matches +// detectMissingVars semantics in nextstep/state.go). +func TestCheckToolboxes_TreatsWhitespaceValueAsMissing(t *testing.T) { + t.Parallel() + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes( + nextstep.ResourceRef{Name: "wst", ServiceName: "svc-a"}, + )), + lookupToolboxEnv: fixedToolboxLookup(map[string]string{ + "TOOLBOX_WST_MCP_ENDPOINT": " ", + }), + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusFail, res.Status) +} + +// ---- Transport error: divergent from C13 (Fail, not Skip) ---- + +func TestCheckToolboxes_FailsOnEnvLookupTransportError(t *testing.T) { + t.Parallel() + wantErr := errors.New("grpc: connection refused") + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes( + nextstep.ResourceRef{Name: "wst", ServiceName: "svc-a"}, + )), + lookupToolboxEnv: func(_ context.Context, _ string) (string, error) { + return "", wantErr + }, + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusFail, res.Status, "transport errors must Fail (not Skip) so the user has an actionable signal") + require.Contains(t, res.Message, "connection refused") + require.Contains(t, res.Suggestion, "azd env") +} + +// ---- Dedup on canonical env key ---- + +func TestCheckToolboxes_dedupsSameToolboxAcrossServices(t *testing.T) { + t.Parallel() + var calls int + deps := Dependencies{ + AzdClient: &azdext.AzdClient{}, + assembleState: fixedAssembler(stateWithToolboxes( + nextstep.ResourceRef{Name: "wst", ServiceName: "svc-a"}, + nextstep.ResourceRef{Name: "wst", ServiceName: "svc-b"}, + )), + lookupToolboxEnv: func(_ context.Context, _ string) (string, error) { + calls++ + return "https://wst.example/mcp", nil + }, + } + res := runToolboxesCheck(t, deps, nil) + require.Equal(t, StatusPass, res.Status) + require.Equal(t, 1, calls, "the same canonical env key must be probed at most once") + require.Equal(t, 1, res.Details["matchedCount"]) +} + +// ---- envkey integration (canonical key alignment) ---- +// +// `normalizeToolboxName`/`toolboxEndpointKey` were folded into the +// shared `internal/pkg/envkey` package so the doctor and the +// provisioning helpers in `internal/cmd/{init,listen}.go` compute +// identical keys. The exhaustive corner-case table lives in +// `internal/pkg/envkey/envkey_test.go`; this thin pin test asserts +// the renderer-facing helper here still routes through it (so a +// future refactor that introduces a local copy in the doctor package +// trips this test). + +func TestDedupToolboxKeys_RoutesThroughSharedHelper(t *testing.T) { + t.Parallel() + refs := []nextstep.ResourceRef{ + {Name: "web-search-tools", ServiceName: "svc"}, + // A name with characters that the previous local normalizer + // would have rendered verbatim ('+', ':', '--'); only the + // shared `envkey.ToolboxMCPEndpoint` collapses them. + {Name: "my+tool", ServiceName: "svc"}, + {Name: "my:tool", ServiceName: "svc"}, + {Name: "my--tool", ServiceName: "svc"}, + } + got := dedupToolboxKeys(refs) + require.Equal(t, []string{ + "TOOLBOX_MY_TOOL_MCP_ENDPOINT", + "TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT", + }, got) +} + +// ---- dedupToolboxKeys helper ---- + +func TestDedupToolboxKeys(t *testing.T) { + t.Parallel() + refs := []nextstep.ResourceRef{ + {Name: "wst", ServiceName: "svc-a"}, + {Name: "wst", ServiceName: "svc-b"}, + {Name: "code-runner", ServiceName: "svc-a"}, + } + got := dedupToolboxKeys(refs) + require.Equal(t, []string{ + "TOOLBOX_CODE_RUNNER_MCP_ENDPOINT", + "TOOLBOX_WST_MCP_ENDPOINT", + }, got) +} + +// ---- Factory-shape pin ---- + +func TestNewCheckToolboxes_FactoryShape(t *testing.T) { + t.Parallel() + c := newCheckToolboxes(Dependencies{}) + require.Equal(t, "local.toolboxes", c.ID) + require.False(t, c.Remote) + require.NotNil(t, c.Fn) + require.NotEmpty(t, c.Name) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/runner.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/runner.go new file mode 100644 index 00000000000..5f2df64af15 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/runner.go @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "time" +) + +// CheckFunc is the signature every check satisfies. Checks are invoked +// sequentially by the Runner; each receives the immutable Options and the +// list of Results produced by prior checks (for downstream checks that +// want to short-circuit when an upstream dependency failed). +// +// The prior slice must be treated as read-only — the Runner passes its +// live append target, and mutating elements would silently corrupt the +// final Report. Read-only inspection of prior[i].Status, .ID, .Name is +// the supported use case. +// +// Returning StatusSkip in response to a missing precondition is preferred +// over StatusFail — the user should not see a "fail" cascade when the +// root cause is a single upstream issue. +type CheckFunc func(ctx context.Context, opts Options, prior []Result) Result + +// ResultObserver is called after each check result is finalized and +// appended to the report. Callers use it to stream text output while the +// runner still owns canonical ID/Name stamping, duration capture, and +// status normalization. +type ResultObserver func(Result) error + +// Check pairs a stable identifier with its execution function. ID is the +// value stamped onto the produced Result (the function itself does not +// populate ID — the Runner does this so the canonical IDs are owned in +// one place and cannot drift from the design's pinned table). Remote +// indicates whether the check requires network access; the Runner skips +// remote checks when Options.LocalOnly is true. +type Check struct { + ID string + Name string + Remote bool + Fn CheckFunc +} + +// Runner executes a list of checks against an azd project and produces a +// Report. The Runner itself is transport-free: it does not touch the +// filesystem, gRPC, or stdout. Checks bring those dependencies via their +// own closures (see checks_local.go). +type Runner struct { + Checks []Check +} + +// Run invokes every configured check, in order, gathering results and +// aggregating the Summary. Cancellation via ctx is honored on a +// best-effort basis: the loop checks ctx.Err() between checks and +// short-circuits the remainder as Skipped with a "cancelled" message. +// +// Run never returns an error — failures are encoded into the per-check +// Status and Message, so callers always receive a complete Report. This +// keeps the JSON envelope shape stable and lets the formatter render +// partial results when the runner is interrupted mid-flight. +func (r *Runner) Run(ctx context.Context, opts Options) Report { + report, _ := r.RunWithObserver(ctx, opts, nil) + return report +} + +// RunWithObserver invokes every configured check exactly like Run, but +// calls observer after each finalized result is appended. If observer +// returns an error, execution stops and the partial Report is returned +// with Summary populated for the results that were already produced. +func (r *Runner) RunWithObserver(ctx context.Context, opts Options, observer ResultObserver) (Report, error) { + report := Report{ + SchemaVersion: CurrentSchemaVersion, + Redacted: !opts.Unredacted, + Checks: make([]Result, 0, len(r.Checks)), + } + + appendResult := func(result Result) error { + report.Checks = append(report.Checks, result) + if observer != nil { + return observer(result) + } + return nil + } + + for _, check := range r.Checks { + if err := ctx.Err(); err != nil { + if err := appendResult(Result{ + ID: check.ID, + Name: check.Name, + Status: StatusSkip, + Message: "cancelled", + }); err != nil { + report.Summary = summarize(report.Checks) + return report, err + } + continue + } + + if opts.LocalOnly && check.Remote { + if err := appendResult(Result{ + ID: check.ID, + Name: check.Name, + Status: StatusSkip, + Message: "remote check excluded by --local-only", + }); err != nil { + report.Summary = summarize(report.Checks) + return report, err + } + continue + } + + // Defensive default for a malformed Check entry — fail loud rather + // than silently dropping the check from the report. + if check.Fn == nil { + if err := appendResult(Result{ + ID: check.ID, + Name: check.Name, + Status: StatusFail, + Message: "internal error: check function is nil", + }); err != nil { + report.Summary = summarize(report.Checks) + return report, err + } + continue + } + + start := time.Now() + result := check.Fn(ctx, opts, report.Checks) + result.DurationMs = time.Since(start).Milliseconds() + // Pin the ID + Name at the runner — the design's table is the + // source of truth, and individual check functions should not be + // able to drift from it. + result.ID = check.ID + result.Name = check.Name + // Normalize the returned Status. The Status type is `type Status + // string`, which is *not* a closed enum at the Go type system + // level: a check could return Status("passed") (a typo) or any + // other string. We coerce any non-canonical value (including + // empty) to StatusFail so the report is honest about the + // internal error and the failed check is visible in summary + + // exit code, rather than silently dropped. + switch result.Status { + case StatusPass, StatusWarn, StatusFail, StatusSkip, StatusInfo: + // canonical — keep as-is + case "": + result.Status = StatusFail + if result.Message == "" { + result.Message = "internal error: check returned empty status" + } + default: + invalid := string(result.Status) + result.Status = StatusFail + if result.Message == "" { + result.Message = "internal error: check returned invalid status: " + invalid + } + } + if check.Remote { + report.Remote = true + } + if err := appendResult(result); err != nil { + report.Summary = summarize(report.Checks) + return report, err + } + } + + report.Summary = summarize(report.Checks) + return report, nil +} + +// summarize counts results by status. Unknown statuses are not expected +// here — the runner normalizes any non-canonical status to StatusFail +// before append — but we still ignore them defensively to keep the +// function robust against an externally-constructed Report. +func summarize(checks []Result) Summary { + var s Summary + for _, c := range checks { + switch c.Status { + case StatusPass: + s.Pass++ + case StatusWarn: + s.Warn++ + case StatusFail: + s.Fail++ + case StatusSkip: + s.Skip++ + case StatusInfo: + s.Info++ + } + } + return s +} + +// ExitCode maps a Report onto the process exit code the doctor command +// should yield: +// +// - 0 — at least one Pass or Info and no Fail (Warn does not raise the +// exit code; Skip does not lower the exit code below 0; Info counts +// as "a useful diagnostic completed" alongside Pass). +// - 1 — any Fail (precedence over everything else). +// - 2 — no useful diagnostic completed (empty report, all-skip, +// warn-only, or any combination of skip + warn without a single +// pass or info). The user needs to fix preconditions and re-run. +// +// A report with zero checks (which Run never produces but a caller might +// synthesize) yields exit code 2 — the "nothing ran" semantics match the +// all-skip case from the user's perspective. +func ExitCode(report Report) int { + if report.Summary.Fail > 0 { + return 1 + } + if report.Summary.Pass == 0 && report.Summary.Info == 0 { + return 2 + } + return 0 +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/runner_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/runner_test.go new file mode 100644 index 00000000000..433328f7c13 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/runner_test.go @@ -0,0 +1,442 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package doctor + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRunner_Run_ProducesReportWithCanonicalIDsAndNames(t *testing.T) { + t.Parallel() + + runner := &Runner{ + Checks: []Check{ + { + ID: "1", + Name: "first", + Fn: func(_ context.Context, _ Options, _ []Result) Result { + // Intentionally leave ID/Name unset on the result — + // the runner is the source of truth for both. + return Result{Status: StatusPass, Message: "ok"} + }, + }, + { + ID: "2", + Name: "second", + Fn: func(_ context.Context, _ Options, _ []Result) Result { + // Pretend the check overrides ID/Name maliciously; + // the runner must clobber both. + return Result{ID: "999", Name: "overridden", Status: StatusFail, Message: "boom"} + }, + }, + }, + } + + report := runner.Run(t.Context(), Options{}) + + require.Equal(t, CurrentSchemaVersion, report.SchemaVersion) + require.True(t, report.Redacted, "redacted defaults to true (inverse of Unredacted)") + require.False(t, report.Remote, "no remote checks ran") + require.Len(t, report.Checks, 2) + require.Equal(t, "1", report.Checks[0].ID) + require.Equal(t, "first", report.Checks[0].Name) + require.Equal(t, "2", report.Checks[1].ID) + require.Equal(t, "second", report.Checks[1].Name, "runner pins Name; check return is ignored") +} + +func TestRunner_RunWithObserver_ObservesFinalizedResultsInOrder(t *testing.T) { + t.Parallel() + + runner := &Runner{ + Checks: []Check{ + { + ID: "1", + Name: "first", + Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{ + ID: "wrong", + Name: "wrong", + Status: StatusPass, + Message: "ok", + } + }, + }, + { + ID: "2", + Name: "remote", + Remote: true, + Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusWarn, Message: "remote warning"} + }, + }, + }, + } + + var observed []Result + report, err := runner.RunWithObserver(t.Context(), Options{}, func(result Result) error { + observed = append(observed, result) + return nil + }) + + require.NoError(t, err) + require.True(t, report.Remote) + require.Equal(t, report.Checks, observed) + require.Equal(t, "1", observed[0].ID) + require.Equal(t, "first", observed[0].Name) + require.Equal(t, StatusPass, observed[0].Status) + require.Equal(t, "2", observed[1].ID) + require.Equal(t, "remote", observed[1].Name) + require.Equal(t, StatusWarn, observed[1].Status) +} + +func TestRunner_RunWithObserver_ObservesBeforeNextCheckStarts(t *testing.T) { + t.Parallel() + + secondStarted := false + observedFirstBeforeSecond := false + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "first", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass} + }}, + {ID: "2", Name: "second", Fn: func(_ context.Context, _ Options, _ []Result) Result { + secondStarted = true + return Result{Status: StatusPass} + }}, + }, + } + + _, err := runner.RunWithObserver(t.Context(), Options{}, func(result Result) error { + if result.ID == "1" { + observedFirstBeforeSecond = !secondStarted + } + return nil + }) + + require.NoError(t, err) + require.True(t, observedFirstBeforeSecond) +} + +func TestRunner_RunWithObserver_ErrorStopsWithPartialSummary(t *testing.T) { + t.Parallel() + + observerErr := errors.New("write failed") + calledSecond := false + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "first", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "ok"} + }}, + {ID: "2", Name: "second", Fn: func(_ context.Context, _ Options, _ []Result) Result { + calledSecond = true + return Result{Status: StatusPass, Message: "should not run"} + }}, + }, + } + + report, err := runner.RunWithObserver(t.Context(), Options{}, func(Result) error { + return observerErr + }) + + require.ErrorIs(t, err, observerErr) + require.False(t, calledSecond) + require.Len(t, report.Checks, 1) + require.Equal(t, Summary{Pass: 1}, report.Summary) +} + +func TestRunner_Run_PriorResultsPassedToSubsequentChecks(t *testing.T) { + t.Parallel() + + var observed []Result + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "first", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "first done"} + }}, + {ID: "2", Name: "second", Fn: func(_ context.Context, _ Options, prior []Result) Result { + observed = append([]Result(nil), prior...) + return Result{Status: StatusPass, Message: "second done"} + }}, + }, + } + + report := runner.Run(t.Context(), Options{}) + + require.Len(t, observed, 1, "second check should see exactly the prior result") + require.Equal(t, "1", observed[0].ID) + require.Equal(t, StatusPass, observed[0].Status) + require.Equal(t, "first done", observed[0].Message) + require.Len(t, report.Checks, 2) +} + +func TestRunner_Run_LocalOnly_SkipsRemoteChecks(t *testing.T) { + t.Parallel() + + called := false + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "local", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "ok"} + }}, + {ID: "7", Name: "remote", Remote: true, Fn: func(_ context.Context, _ Options, _ []Result) Result { + called = true + return Result{Status: StatusPass, Message: "should not run"} + }}, + }, + } + + report := runner.Run(t.Context(), Options{LocalOnly: true}) + + require.False(t, called, "remote check function must not be invoked when LocalOnly is true") + require.False(t, report.Remote, "Remote flag should remain false when only local checks executed") + require.Equal(t, StatusSkip, report.Checks[1].Status) + require.Contains(t, report.Checks[1].Message, "local-only") +} + +func TestRunner_Run_RemoteCheck_FlipsReportRemoteFlag(t *testing.T) { + t.Parallel() + + runner := &Runner{ + Checks: []Check{ + {ID: "7", Name: "remote", Remote: true, Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "ok"} + }}, + }, + } + + report := runner.Run(t.Context(), Options{}) + + require.True(t, report.Remote, "any executed remote check should flip the Remote flag") +} + +func TestRunner_Run_NilCheckFn_YieldsFailResult(t *testing.T) { + t.Parallel() + + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "malformed", Fn: nil}, + }, + } + + report := runner.Run(t.Context(), Options{}) + + require.Len(t, report.Checks, 1) + require.Equal(t, StatusFail, report.Checks[0].Status) + require.Contains(t, report.Checks[0].Message, "internal error") +} + +func TestRunner_Run_EmptyStatus_NormalizedToFail(t *testing.T) { + t.Parallel() + + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "buggy", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Message: "did not set status"} + }}, + }, + } + + report := runner.Run(t.Context(), Options{}) + + require.Equal(t, StatusFail, report.Checks[0].Status, + "empty status must be normalized to Fail so the bug is visible in the report") +} + +func TestRunner_Run_UnknownStatus_NormalizedToFail(t *testing.T) { + t.Parallel() + + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "typo-status", Fn: func(_ context.Context, _ Options, _ []Result) Result { + // Status is `type Status string`, not a closed enum at the type-system level. + // A typo (or any non-canonical value) must be normalized so it isn't dropped + // from Summary aggregation and the exit code. + return Result{Status: Status("passed"), Message: "real check would have meant pass"} + }}, + }, + } + + report := runner.Run(t.Context(), Options{}) + + require.Len(t, report.Checks, 1) + require.Equal(t, StatusFail, report.Checks[0].Status, "non-canonical status must be normalized to Fail") + require.Equal(t, "real check would have meant pass", report.Checks[0].Message, + "existing non-empty Message must be preserved (the bug is in Status, not Message)") + require.Equal(t, 1, report.Summary.Fail, "the normalized fail must count toward Summary") + require.Equal(t, 1, ExitCode(report), "a single normalized fail must drive exit code 1") +} + +func TestRunner_Run_UnknownStatus_EmptyMessage_AnnotatedWithInternalError(t *testing.T) { + t.Parallel() + + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "typo-status", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: Status("ok")} + }}, + }, + } + + report := runner.Run(t.Context(), Options{}) + + require.Equal(t, StatusFail, report.Checks[0].Status) + require.Contains(t, report.Checks[0].Message, "internal error") + require.Contains(t, report.Checks[0].Message, "ok", + "the offending value should appear in the error message so the bug is debuggable from the report alone") +} + +func TestRunner_Run_ContextCancelled_RemainingChecksSkipped(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + + runner := &Runner{ + Checks: []Check{ + {ID: "1", Name: "first", Fn: func(_ context.Context, _ Options, _ []Result) Result { + cancel() + return Result{Status: StatusPass, Message: "ok"} + }}, + {ID: "2", Name: "second", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "should not run"} + }}, + {ID: "3", Name: "third", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "should not run"} + }}, + }, + } + + report := runner.Run(ctx, Options{}) + + require.Len(t, report.Checks, 3) + require.Equal(t, StatusPass, report.Checks[0].Status) + require.Equal(t, StatusSkip, report.Checks[1].Status) + require.Equal(t, "cancelled", report.Checks[1].Message) + require.Equal(t, StatusSkip, report.Checks[2].Status) +} + +func TestRunner_Run_SummaryAggregation(t *testing.T) { + t.Parallel() + + statuses := []Status{StatusPass, StatusPass, StatusWarn, StatusFail, StatusSkip} + checks := make([]Check, 0, len(statuses)) + for i, s := range statuses { + checks = append(checks, Check{ + ID: string(rune('a' + i)), + Name: "x", + Fn: func(_ context.Context, _ Options, _ []Result) Result { return Result{Status: s, Message: "x"} }, + }) + } + + runner := &Runner{Checks: checks} + report := runner.Run(t.Context(), Options{}) + + require.Equal(t, 2, report.Summary.Pass) + require.Equal(t, 1, report.Summary.Warn) + require.Equal(t, 1, report.Summary.Fail) + require.Equal(t, 1, report.Summary.Skip) +} + +func TestExitCode(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + report Report + want int + }{ + { + name: "any fail wins", + report: Report{ + Checks: []Result{{Status: StatusPass}, {Status: StatusFail}, {Status: StatusSkip}}, + Summary: Summary{Pass: 1, Fail: 1, Skip: 1}, + }, + want: 1, + }, + { + name: "all skip yields 2", + report: Report{ + Checks: []Result{{Status: StatusSkip}, {Status: StatusSkip}}, + Summary: Summary{Skip: 2}, + }, + want: 2, + }, + { + name: "no checks yields 2", + report: Report{Checks: nil, Summary: Summary{}}, + want: 2, + }, + { + name: "pass + skip mixed yields 0", + report: Report{ + Checks: []Result{{Status: StatusPass}, {Status: StatusSkip}}, + Summary: Summary{Pass: 1, Skip: 1}, + }, + want: 0, + }, + { + name: "pass + warn yields 0", + report: Report{ + Checks: []Result{{Status: StatusPass}, {Status: StatusWarn}}, + Summary: Summary{Pass: 1, Warn: 1}, + }, + want: 0, + }, + { + name: "warn-only yields 2", + report: Report{ + Checks: []Result{{Status: StatusWarn}}, + Summary: Summary{Warn: 1}, + }, + want: 2, + }, + { + name: "warn + skip without pass yields 2", + report: Report{ + Checks: []Result{{Status: StatusWarn}, {Status: StatusSkip}}, + Summary: Summary{Warn: 1, Skip: 1}, + }, + want: 2, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, ExitCode(tc.report)) + }) + } +} + +func TestRunner_Run_UnredactedFlipsRedacted(t *testing.T) { + t.Parallel() + + runner := &Runner{Checks: []Check{{ID: "1", Name: "x", Fn: func(_ context.Context, _ Options, _ []Result) Result { + return Result{Status: StatusPass, Message: "ok"} + }}}} + + report := runner.Run(t.Context(), Options{Unredacted: true}) + + require.False(t, report.Redacted, "Unredacted true should flip Redacted to false") +} + +func TestRunner_Run_DurationMsIsPopulated(t *testing.T) { + t.Parallel() + + runner := &Runner{Checks: []Check{{ID: "1", Name: "x", Fn: func(_ context.Context, _ Options, _ []Result) Result { + // Sleep long enough that even a millisecond-resolution clock observes a tick. + time.Sleep(5 * time.Millisecond) + return Result{Status: StatusPass, Message: "ok"} + }}}} + + report := runner.Run(t.Context(), Options{}) + + require.Len(t, report.Checks, 1) + require.GreaterOrEqual(t, report.Checks[0].DurationMs, int64(1), + "runner must time each check and stamp DurationMs (got %d)", report.Checks[0].DurationMs) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/types.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/types.go new file mode 100644 index 00000000000..e0491d35bb3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/types.go @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package doctor implements `azd ai agent doctor` — a diagnostics command +// that runs a sequence of local checks (and, in a follow-up phase, remote +// checks) over the current azd project and prints a structured report. +// +// The package is split along two seams: +// +// - This file (types.go) declares the data model: Status, Result, +// Report, and Options. These are the shapes the runner produces and +// the formatters consume; both seams pin against them. +// - runner.go declares the Runner — a transport-free engine that +// iterates a check list, gathers results, and computes the exit code. +// +// Check implementations live in checks_local.go (and, in phase 5, +// checks_remote.go). The Cobra wiring and formatters live in the parent +// internal/cmd package so this package has no Cobra / IO dependencies and +// can be unit-tested without a process-level shim. +package doctor + +// CurrentSchemaVersion is the version stamped onto the JSON envelope. The +// value matches the design spec (`docs/design/azd-ai-agent-nextsteps.md`, +// "Exit codes & JSON output" section). Bump on non-additive shape changes; +// additive changes (new optional fields, new status values) do not require +// a bump. +const CurrentSchemaVersion = "1.0" + +// Status is the outcome of a single check. The set is closed; runners and +// formatters branch exhaustively on these four values. +type Status string + +const ( + // StatusPass — the check succeeded; no follow-up needed. + StatusPass Status = "pass" + // StatusWarn — the check completed but flagged a soft issue the user + // may want to address. Does NOT contribute to a non-zero exit code. + StatusWarn Status = "warn" + // StatusFail — the check completed and identified a blocker. Drives + // exit code 1 (see ExitCode in runner.go). + StatusFail Status = "fail" + // StatusSkip — the check did not run (precondition unmet, --local-only + // excluded it, or an upstream dependency failed). Does NOT contribute + // to a non-zero exit code on its own; a report consisting entirely of + // skips yields exit code 2. + StatusSkip Status = "skip" + // StatusInfo — the check completed cleanly but the result is + // primarily informational (no action required, no problem detected). + // Used by checks whose value is the listing they produce rather than + // a pass/fail verdict — e.g., `remote.agent-identity-roles` renders + // the agent's role assignments so the user can confirm they match + // their mental model. Does NOT contribute to a non-zero exit code. + StatusInfo Status = "info" +) + +// Result captures the outcome of one check. +// +// ID is a stable namespaced identifier (`local.azure-yaml`, +// `remote.rbac`, etc.). Name is a short human-readable title; Message is +// the one-line summary that always renders. Suggestion is a single +// actionable command or instruction (the text formatter renders it after +// the message, indented). Links is an optional slice of URLs (TSG pages, +// learn.microsoft.com docs) that the formatter renders below the +// suggestion. DurationMs is populated by the Runner per check. +// +// JSON tags are extension-owned: the wire shape includes `links` and +// `durationMs` (matching the design spec at +// `docs/design/azd-ai-agent-nextsteps.md`) plus a `details` extension +// field (omitted from the design's example but required for Phase 5 +// remote checks that surface structured payload — role lists, scope +// ARNs). `details` is `omitempty`, so consumers built against the +// design's schema ignore the extra field and remain compatible. +type Result struct { + ID string `json:"id"` + Name string `json:"name"` + Status Status `json:"status"` + Message string `json:"message,omitempty"` + Suggestion string `json:"suggestion,omitempty"` + Links []string `json:"links,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +// Summary is the aggregate count of results by status. Computed by the +// runner; consumers should not mutate it. +type Summary struct { + Pass int `json:"pass"` + Warn int `json:"warn"` + Fail int `json:"fail"` + Skip int `json:"skip"` + Info int `json:"info"` +} + +// Report is the full structured output of a doctor run. SchemaVersion is +// the contract version (see CurrentSchemaVersion). Remote is true when +// remote checks (phase 5) ran; false when --local-only or when no remote +// checks are wired. Redacted is the inverse of the --unredacted flag and +// indicates whether the formatter scrubbed identifiers in user-facing +// strings. +// +// Summary is computed by the Runner for ExitCode and the text formatter, +// but is excluded from the JSON envelope (consumers iterate Checks if they +// need totals). Excluding it keeps the wire shape aligned with the design +// spec. +type Report struct { + SchemaVersion string `json:"schemaVersion"` + Remote bool `json:"remote"` + Redacted bool `json:"redacted"` + Checks []Result `json:"checks"` + Summary Summary `json:"-"` +} + +// Options are the runtime flags that influence the runner. LocalOnly +// excludes any check whose Remote field is true. Today the remote- +// checks factory (doctor.NewRemoteChecks) returns an empty slice, so +// the flag has no observable effect; the wire is fully exercised in +// the runner and tests so C11+ remote checks land transparently. +// Unredacted inverts Redacted on the produced Report; it is also +// surfaced to checks that decide whether to include identifiers in +// their Message / Details strings. +type Options struct { + LocalOnly bool + Unredacted bool +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor_format.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor_format.go new file mode 100644 index 00000000000..3383ceb3eda --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor_format.go @@ -0,0 +1,500 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "io" + "strings" + "unicode" + "unicode/utf8" + + "azureaiagent/internal/cmd/doctor" + "azureaiagent/internal/cmd/nextstep" +) + +// Category labels emitted as section headers in the doctor report. They are +// derived from a check's stable ID prefix; see categoryForCheck. +const ( + categoryLocal = "Local" + categoryAuth = "Authentication" + categoryRemote = "Remote" +) + +// doctorRenderState streams a doctor report to a writer. It tracks the +// previously rendered category so section headers (Local / Authentication / +// Remote) are emitted exactly once, in stream order. The same state object is +// shared by both the streaming (`runAndRenderDoctorText`) and buffered +// (`printDoctorReportText`) paths so their outputs match byte-for-byte. +type doctorRenderState struct { + w io.Writer + debug bool + headed bool + lastCat string +} + +// newDoctorRenderer creates a renderer for one doctor run. `debug=false` +// produces the default concise output; `debug=true` produces the verbose +// per-check Message/Suggestion/Links block. The persistent root `--debug` +// flag controls this. +func newDoctorRenderer(w io.Writer, debug bool) *doctorRenderState { + return &doctorRenderState{w: w, debug: debug} +} + +// writeHeader emits the report title; safe to call only once. +func (r *doctorRenderState) writeHeader() error { + if r.headed { + return nil + } + r.headed = true + _, err := fmt.Fprintln(r.w, "azd ai agent doctor") + return err +} + +// writeCheck emits a single check result. It emits a section header on the +// first check of a new category. Detail rendering depends on r.debug. +func (r *doctorRenderState) writeCheck(c doctor.Result) error { + cat := categoryForCheck(c.ID) + if cat != r.lastCat { + if _, err := fmt.Fprintln(r.w); err != nil { + return err + } + if _, err := fmt.Fprintln(r.w, cat); err != nil { + return err + } + r.lastCat = cat + } + + if r.debug { + return r.writeCheckVerbose(c) + } + return r.writeCheckConcise(c) +} + +// writeFooter emits the summary line and, when applicable, an actionable +// "To fix" block (on failure) or a "Next:" block (on all-green). `showNext` +// gates the all-green block to TTY callers; the failure block always renders. +func (r *doctorRenderState) writeFooter( + report doctor.Report, + trailing []nextstep.Suggestion, + showNext bool, +) error { + if _, err := fmt.Fprintln(r.w); err != nil { + return err + } + if err := writeSummaryLine(r.w, report.Summary); err != nil { + return err + } + + if report.Summary.Fail > 0 { + return writeToFixBlock(r.w, report) + } + if showNext { + return nextstep.PrintAllNext(r.w, trailing) + } + return nil +} + +// printDoctorReportText is the buffered (non-streaming) entry point. It is +// used by tests and any caller that has a fully assembled Report. The flow +// matches the streaming path exactly so test assertions on the streaming +// path (TestPrintDoctorReportText_StreamingPiecesMatchBufferedReport) hold. +func printDoctorReportText( + w io.Writer, + report doctor.Report, + trailing []nextstep.Suggestion, + showNext bool, + debug bool, +) error { + r := newDoctorRenderer(w, debug) + if err := r.writeHeader(); err != nil { + return err + } + for _, c := range report.Checks { + if err := r.writeCheck(c); err != nil { + return err + } + } + return r.writeFooter(report, trailing, showNext) +} + +// writeCheckConcise emits the default-mode line for a check: +// +// (✓) +// (x) +// +// fix: +// +// PASS suppresses Message/Suggestion/Links to keep the report scannable. +// FAIL/WARN keeps a one-line Message + Suggestion. SKIP inlines the reason +// after "-- skipped" when the Message starts with "skipped: ". +func (r *doctorRenderState) writeCheckConcise(c doctor.Result) error { + glyph := statusGlyph(c.Status) + switch c.Status { + case doctor.StatusPass: + _, err := fmt.Fprintf(r.w, " %s %s\n", glyph, c.Name) + return err + case doctor.StatusSkip: + reason := strings.TrimPrefix(c.Message, "skipped: ") + reason = firstLine(reason) + if reason == "" { + _, err := fmt.Fprintf(r.w, " %s %s -- skipped\n", glyph, c.Name) + return err + } + _, err := fmt.Fprintf(r.w, " %s %s -- skipped (%s)\n", glyph, c.Name, reason) + return err + default: + // FAIL / WARN / INFO / UNKN + if _, err := fmt.Fprintf(r.w, " %s %s\n", glyph, c.Name); err != nil { + return err + } + if msg := firstLine(c.Message); msg != "" { + if _, err := fmt.Fprintf(r.w, " %s\n", capitalize(msg)); err != nil { + return err + } + } + if sug := firstLine(c.Suggestion); sug != "" { + if _, err := fmt.Fprintf(r.w, " fix: %s\n", capitalize(sug)); err != nil { + return err + } + } + return nil + } +} + +// writeCheckVerbose emits the --debug mode line for a check; preserves the +// full Message, Suggestion, and Links contents and capitalizes the first +// letter of each so the output matches the user-visible feedback in #8198. +func (r *doctorRenderState) writeCheckVerbose(c doctor.Result) error { + glyph := statusGlyph(c.Status) + if _, err := fmt.Fprintf(r.w, " %s %s\n", glyph, c.Name); err != nil { + return err + } + if c.Message != "" { + if err := writeIndentedBlock(r.w, " ", capitalize(c.Message)); err != nil { + return err + } + } + if c.Suggestion != "" { + if err := writeIndentedBlock(r.w, " fix: ", capitalize(c.Suggestion)); err != nil { + return err + } + } + for _, link := range c.Links { + if _, err := fmt.Fprintf(r.w, " %s\n", link); err != nil { + return err + } + } + return nil +} + +// writeIndentedBlock writes a multi-line block with the given prefix on the +// first line and a same-width whitespace prefix on continuation lines. +func writeIndentedBlock(w io.Writer, prefix, body string) error { + lines := strings.Split(body, "\n") + if len(lines) == 0 { + return nil + } + contPrefix := strings.Repeat(" ", utf8.RuneCountInString(prefix)) + for i, line := range lines { + if i == 0 { + if _, err := fmt.Fprintf(w, "%s%s\n", prefix, line); err != nil { + return err + } + continue + } + if _, err := fmt.Fprintf(w, "%s%s\n", contPrefix, line); err != nil { + return err + } + } + return nil +} + +// writeSummaryLine emits the aggregate counts in the new "X passed, Y +// failed, Z skipped" format. Warn and Info segments are appended only when +// non-zero so the line stays uncluttered for the common case. +func writeSummaryLine(w io.Writer, s doctor.Summary) error { + if s.Pass == 0 && s.Warn == 0 && s.Fail == 0 && s.Skip == 0 && s.Info == 0 { + _, err := fmt.Fprintln(w, "No checks executed") + return err + } + parts := []string{ + fmt.Sprintf("%d passed", s.Pass), + fmt.Sprintf("%d failed", s.Fail), + fmt.Sprintf("%d skipped", s.Skip), + } + if s.Warn > 0 { + parts = append(parts, fmt.Sprintf("%d warned", s.Warn)) + } + if s.Info > 0 { + parts = append(parts, fmt.Sprintf("%d info", s.Info)) + } + _, err := fmt.Fprintln(w, strings.Join(parts, ", ")) + return err +} + +// writeToFixBlock emits the "To fix" footer on failed reports. When at least +// one failed check maps to a canonical remediation (`remediationForCheckID`), +// the footer is a numbered, deduplicated command list in execution order +// (auth → provision → deploy). When all failures are unmapped (or there are +// any unmapped failures alongside mapped ones), the footer also defers to +// the per-check `fix:` notes rendered in the body for full coverage. The +// re-run instruction always closes the block so the user knows how to +// re-validate. +func writeToFixBlock(w io.Writer, report doctor.Report) error { + if report.Summary.Fail == 0 { + return nil + } + actions := orderedRemediations(report) + unmapped := hasUnmappedFailure(report) + if _, err := fmt.Fprintln(w); err != nil { + return err + } + switch { + case len(actions) > 0: + if _, err := fmt.Fprintln(w, "To fix, run these commands in order:"); err != nil { + return err + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + cmdWidth := 0 + for _, a := range actions { + if n := len(a.command); n > cmdWidth { + cmdWidth = n + } + } + for i, a := range actions { + pad := strings.Repeat(" ", cmdWidth-len(a.command)) + if _, err := fmt.Fprintf(w, " %d. %s%s -- %s\n", i+1, a.command, pad, a.desc); err != nil { + return err + } + } + if unmapped { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + if _, err := fmt.Fprintln( + w, "Also review the fix: notes above for any remaining failed checks.", + ); err != nil { + return err + } + } + default: + if _, err := fmt.Fprintln(w, "To fix:"); err != nil { + return err + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + if _, err := fmt.Fprintln( + w, " Review the fix: notes above for each failed check.", + ); err != nil { + return err + } + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + _, err := fmt.Fprintln(w, "Then re-run `azd ai agent doctor` to verify.") + return err +} + +// hasUnmappedFailure reports whether any failed check lacks a canonical +// remediation entry in `remediationForCheckID`. Used by `writeToFixBlock` +// to know when to append the "also review fix: notes" pointer. +func hasUnmappedFailure(report doctor.Report) bool { + for _, c := range report.Checks { + if c.Status != doctor.StatusFail { + continue + } + if _, ok := remediationForCheckID(c.ID); !ok { + return true + } + } + return false +} + +// remediation is one row in the "To fix" block. +type remediation struct { + command string + desc string + order int +} + +// orderedRemediations maps failed check IDs onto an ordered, deduplicated +// list of remediations. The order field expresses the canonical execution +// sequence (login → provision → deploy → init). +func orderedRemediations(report doctor.Report) []remediation { + seen := map[string]remediation{} + for _, c := range report.Checks { + if c.Status != doctor.StatusFail { + continue + } + r, ok := remediationForCheckID(c.ID) + if !ok { + continue + } + // First failed check wins so deterministic remediation text is preserved. + if _, exists := seen[r.command]; !exists { + seen[r.command] = r + } + } + out := make([]remediation, 0, len(seen)) + for _, r := range seen { + out = append(out, r) + } + // stable sort by canonical order + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j-1].order > out[j].order; j-- { + out[j-1], out[j] = out[j], out[j-1] + } + } + return out +} + +// remediationForCheckID is the canonical mapping from failed check ID to +// remediation command. Unknown IDs return ok=false; the per-check fix: text +// is the source of truth in that case. +func remediationForCheckID(id string) (remediation, bool) { + switch id { + case "remote.auth": + return remediation{command: "azd auth login", desc: "sign in to Azure", order: 10}, true + case "remote.foundry-endpoint", + "remote.model-deployments", + "remote.connections", + "remote.agent-identity-roles": + return remediation{ + command: "azd provision", + desc: "create the missing Foundry resources", + order: 20, + }, true + case "remote.agent-status": + return remediation{ + command: "azd deploy", + desc: "deploy the agent(s)", + order: 30, + }, true + case "local.azure-yaml", "local.agent-service-detected": + return remediation{ + command: "azd ai agent init", + desc: "scaffold or refresh the agent project", + order: 5, + }, true + case "local.environment-selected": + return remediation{ + command: "azd env new", + desc: "create or select an azd environment", + order: 1, + }, true + } + return remediation{}, false +} + +// categoryForCheck derives the section label from a check's stable ID. The +// `remote.auth` check is split into its own Authentication section because +// it is the only credential-related probe and reads more naturally on its +// own line in the rendered report. +func categoryForCheck(id string) string { + switch { + case id == "remote.auth": + return categoryAuth + case strings.HasPrefix(id, "remote."): + return categoryRemote + case strings.HasPrefix(id, "local."): + return categoryLocal + default: + return categoryRemote + } +} + +// statusGlyph returns the bracketed indicator emitted before each check +// name. The format intentionally mirrors common CLI conventions (e.g., +// `(✓)`, `(x)`) for low-effort scanning. +func statusGlyph(s doctor.Status) string { + switch s { + case doctor.StatusPass: + return "(✓)" + case doctor.StatusWarn: + return "(!)" + case doctor.StatusFail: + return "(x)" + case doctor.StatusSkip: + return "(-)" + case doctor.StatusInfo: + return "(ⓘ)" + default: + return "(?)" + } +} + +// statusGlyphAndLabel is retained for tests that pin the per-status glyph +// contract. New rendering code uses statusGlyph directly because the new +// format does not include a fixed-width label segment. +func statusGlyphAndLabel(s doctor.Status) (string, string) { + switch s { + case doctor.StatusPass: + return "(✓)", "PASS" + case doctor.StatusWarn: + return "(!)", "WARN" + case doctor.StatusFail: + return "(x)", "FAIL" + case doctor.StatusSkip: + return "(-)", "SKIP" + case doctor.StatusInfo: + return "(ⓘ)", "INFO" + default: + return "(?)", "UNKN" + } +} + +// firstLine returns the first line of s with surrounding whitespace +// trimmed. It is the concise-mode mechanism for collapsing a multi-line +// Message or Suggestion to a single scannable line. +func firstLine(s string) string { + s = strings.TrimSpace(s) + if idx := strings.IndexByte(s, '\n'); idx >= 0 { + s = s[:idx] + } + return strings.TrimSpace(s) +} + +// capitalize uppercases the first rune of s while leaving the remainder +// untouched. It is a no-op when s begins with a non-letter (e.g., a number, +// an env var like AZURE_AI_PROJECT_ENDPOINT, or a backtick-quoted token), +// when s is already capitalized, or when s begins with a known brand-name +// prefix that is conventionally lowercase ("azd", "azure.yaml", "agent.yaml", +// "skipped:", "cancelled"). The render-layer rule mirrors the convention in +// the user-visible mock in PR #8198 review 4331086010. +func capitalize(s string) string { + if s == "" { + return s + } + r, size := utf8.DecodeRuneInString(s) + if !unicode.IsLetter(r) || unicode.IsUpper(r) { + return s + } + lower := strings.ToLower(s) + for _, prefix := range noCapitalizePrefixes { + if strings.HasPrefix(lower, prefix) { + return s + } + } + return string(unicode.ToUpper(r)) + s[size:] +} + +// noCapitalizePrefixes lists lowercase-leading prefixes that must remain +// lowercase in the rendered report. Each prefix is matched case-insensitively +// against the start of the string. Add new entries only for brand names, +// command names, or token prefixes that read more naturally lowercase in +// terminal output (e.g., `azd`, `azure.yaml`). +var noCapitalizePrefixes = []string{ + "azd ", + "azd.", + "azure.yaml", + "agent.yaml", + "agent.manifest.yaml", + "skipped:", + "skipped ", +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor_format_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor_format_test.go new file mode 100644 index 00000000000..54bc3d03b07 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor_format_test.go @@ -0,0 +1,509 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "azureaiagent/internal/cmd/doctor" + "azureaiagent/internal/cmd/nextstep" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// renderConcise / renderVerbose are tiny wrappers to keep the test bodies +// readable; both flow through printDoctorReportText so streaming parity is +// implicitly exercised by TestPrintDoctorReportText_StreamingPiecesMatch +// BufferedReport below. + +func renderConcise(t *testing.T, r doctor.Report, trailing []nextstep.Suggestion, showNext bool) string { + t.Helper() + var buf bytes.Buffer + require.NoError(t, printDoctorReportText(&buf, r, trailing, showNext, false)) + return buf.String() +} + +func renderVerbose(t *testing.T, r doctor.Report, trailing []nextstep.Suggestion, showNext bool) string { + t.Helper() + var buf bytes.Buffer + require.NoError(t, printDoctorReportText(&buf, r, trailing, showNext, true)) + return buf.String() +} + +// TestPrintDoctorReportText_ConciseDefaults locks the default (non-debug) +// rendering contract: PASS shows just the check name, FAIL shows a one-line +// Message + Suggestion, SKIP inlines the skip reason after "-- skipped". +func TestPrintDoctorReportText_ConciseDefaults(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "local.grpc-extension", Name: "azd extension reachable", + Status: doctor.StatusPass, Message: "running"}, + {ID: "local.azure-yaml", Name: "azure.yaml valid", + Status: doctor.StatusFail, + Message: "no azure.yaml in current directory", + Suggestion: "azd ai agent init", + Links: []string{"https://aka.ms/azd-ai-agent-init"}}, + {ID: "local.environment-selected", Name: "azd environment selected", + Status: doctor.StatusSkip, Message: "skipped: upstream check blocked"}, + }, + Summary: doctor.Summary{Pass: 1, Fail: 1, Skip: 1}, + } + + got := renderConcise(t, report, nil, false) + + assert.True(t, strings.HasPrefix(got, "azd ai agent doctor\n"), "header line") + assert.Contains(t, got, "\nLocal\n", "Local section header emitted") + assert.Contains(t, got, " (✓) azd extension reachable\n", "PASS: name only") + assert.NotContains(t, got, " running", "PASS suppresses Message in concise mode") + assert.Contains(t, got, " (x) azure.yaml valid\n", "FAIL glyph") + assert.Contains(t, got, " No azure.yaml in current directory", "Message capitalized + included") + assert.Contains(t, got, " fix: azd ai agent init", + "Suggestion keeps lowercase 'azd' brand-name prefix") + assert.NotContains(t, got, "https://aka.ms/azd-ai-agent-init", "Links suppressed in concise mode") + assert.Contains(t, got, " (-) azd environment selected -- skipped (upstream check blocked)\n", + "SKIP inlines reason after '-- skipped'") + assert.Contains(t, got, "1 passed, 1 failed, 1 skipped", "summary line") + assert.NotContains(t, got, "0 warned", "warn count must be hidden when zero") + assert.NotContains(t, got, "0 info", "info count must be hidden when zero") +} + +// TestPrintDoctorReportText_VerboseDebug locks the --debug rendering: full +// Message + full Suggestion + Links are emitted, with first-letter +// capitalization applied to Message/Suggestion. +func TestPrintDoctorReportText_VerboseDebug(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "local.grpc-extension", Name: "azd extension reachable", + Status: doctor.StatusPass, Message: "azd extension reachable (version 0.1.32-preview)"}, + {ID: "local.azure-yaml", Name: "azure.yaml valid", + Status: doctor.StatusFail, + Message: "no azure.yaml in current directory\nrun init from your project root", + Suggestion: "azd ai agent init", + Links: []string{"https://aka.ms/azd-ai-agent-init"}}, + }, + Summary: doctor.Summary{Pass: 1, Fail: 1}, + } + + got := renderVerbose(t, report, nil, false) + + assert.Contains(t, got, " (✓) azd extension reachable\n") + assert.Contains(t, got, " azd extension reachable (version 0.1.32-preview)", + "verbose mode keeps full Message; 'azd' lead stays lowercase") + assert.Contains(t, got, " No azure.yaml in current directory\n") + assert.Contains(t, got, " run init from your project root\n", + "continuation line preserved at same indent") + assert.Contains(t, got, " fix: azd ai agent init", + "Suggestion 'azd' prefix stays lowercase in verbose mode") + assert.Contains(t, got, " https://aka.ms/azd-ai-agent-init", "Links rendered in verbose mode") +} + +// TestPrintDoctorReportText_Sections verifies that checks are grouped by +// category (Local / Authentication / Remote) with one blank line + header +// between groups and that remote.auth is broken out as its own section. +func TestPrintDoctorReportText_Sections(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "local.grpc-extension", Name: "azd extension reachable", Status: doctor.StatusPass}, + {ID: "remote.auth", Name: "authentication", Status: doctor.StatusPass}, + {ID: "remote.foundry-endpoint", Name: "Foundry project endpoint reachable", + Status: doctor.StatusPass}, + }, + Summary: doctor.Summary{Pass: 3}, + } + + got := renderConcise(t, report, nil, false) + + localIdx := strings.Index(got, "\nLocal\n") + authIdx := strings.Index(got, "\nAuthentication\n") + remoteIdx := strings.Index(got, "\nRemote\n") + + require.GreaterOrEqual(t, localIdx, 0, "Local section header present") + require.GreaterOrEqual(t, authIdx, 0, "Authentication section header present") + require.GreaterOrEqual(t, remoteIdx, 0, "Remote section header present") + assert.Less(t, localIdx, authIdx, "Local precedes Authentication") + assert.Less(t, authIdx, remoteIdx, "Authentication precedes Remote") +} + +// TestPrintDoctorReportText_StreamingPiecesMatchBufferedReport locks the +// parity contract between the streaming path (writeHeader/writeCheck/ +// writeFooter called per result) and the buffered path +// (printDoctorReportText with a fully-assembled Report). +// TestPrintDoctorReportText_StreamingPiecesMatchBufferedReport verifies that +// the streaming render path (header → per-check writes → footer) produces +// byte-identical output to the buffered `printDoctorReportText`. The matrix +// covers both concise (`debug=false`) and verbose (`debug=true`) modes so a +// future change that diverges either branch is caught immediately. The +// fixture exercises the verbose-only branches: a PASS with Message detail, +// a multi-line Suggestion (so `writeIndentedBlock` runs), and Links. +func TestPrintDoctorReportText_StreamingPiecesMatchBufferedReport(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "local.grpc-extension", Name: "azd extension reachable", + Status: doctor.StatusPass, + Message: "running"}, + {ID: "local.azure-yaml", Name: "azure.yaml valid", + Status: doctor.StatusFail, + Message: "no azure.yaml in current directory", + Suggestion: "azd ai agent init\nthen re-run doctor", + Links: []string{"https://aka.ms/azd-ai-agent-init"}}, + {ID: "remote.auth", Name: "authentication", + Status: doctor.StatusSkip, + Message: "skipped: local-only"}, + }, + Summary: doctor.Summary{Pass: 1, Fail: 1, Skip: 1}, + } + trailing := []nextstep.Suggestion{ + {Command: "azd ai agent run", Description: "start the agent locally", Priority: 10}, + } + + cases := []struct { + name string + debug bool + }{ + {name: "concise", debug: false}, + {name: "verbose", debug: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buffered bytes.Buffer + require.NoError(t, printDoctorReportText(&buffered, report, trailing, true, tc.debug)) + + var streamed bytes.Buffer + streamRenderer := newDoctorRenderer(&streamed, tc.debug) + require.NoError(t, streamRenderer.writeHeader()) + for _, result := range report.Checks { + require.NoError(t, streamRenderer.writeCheck(result)) + } + require.NoError(t, streamRenderer.writeFooter(report, trailing, true)) + + assert.Equal(t, buffered.String(), streamed.String()) + }) + } +} + +// TestPrintDoctorReportText_TrailingNextOnAllGreen verifies the "Next:" +// footer block follows the summary on a clean report. +func TestPrintDoctorReportText_TrailingNextOnAllGreen(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{{ID: "local.grpc-extension", Name: "azd extension reachable", Status: doctor.StatusPass}}, + Summary: doctor.Summary{Pass: 1}, + } + trailing := []nextstep.Suggestion{ + {Command: "azd ai agent run", Description: "start the agent locally", Priority: 10}, + } + + got := renderConcise(t, report, trailing, true) + + assert.Contains(t, got, "Next:") + assert.Contains(t, got, "azd ai agent run") + sumIdx := strings.Index(got, "1 passed") + nextIdx := strings.Index(got, "Next:") + require.GreaterOrEqual(t, sumIdx, 0) + require.GreaterOrEqual(t, nextIdx, 0) + assert.Less(t, sumIdx, nextIdx) +} + +// TestPrintDoctorReportText_TrailingSuppressedWhenShowNextFalse verifies +// that showNext=false hides the "Next:" block even when trailing is +// non-empty (e.g., non-TTY caller). +func TestPrintDoctorReportText_TrailingSuppressedWhenShowNextFalse(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{{ID: "local.grpc-extension", Name: "azd extension reachable", Status: doctor.StatusPass}}, + Summary: doctor.Summary{Pass: 1}, + } + trailing := []nextstep.Suggestion{ + {Command: "azd ai agent run", Description: "start the agent locally", Priority: 10}, + } + + got := renderConcise(t, report, trailing, false) + assert.NotContains(t, got, "Next:") + assert.NotContains(t, got, "azd ai agent run") +} + +// TestPrintDoctorReportText_ToFixBlockOnFailure verifies the actionable +// "To fix" footer is emitted on failure, with commands in the canonical +// remediation order (login → provision → deploy) and deduplicated across +// multiple failed checks that map to the same command. +func TestPrintDoctorReportText_ToFixBlockOnFailure(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "remote.auth", Name: "authentication", Status: doctor.StatusPass}, + {ID: "remote.foundry-endpoint", Name: "Foundry endpoint", + Status: doctor.StatusFail, Message: "endpoint unreachable"}, + {ID: "remote.model-deployments", Name: "model deployments", + Status: doctor.StatusFail, Message: "model missing"}, + {ID: "remote.agent-status", Name: "agents active", + Status: doctor.StatusFail, Message: "1 of 1 agents have not been deployed"}, + }, + Summary: doctor.Summary{Pass: 1, Fail: 3}, + } + + got := renderConcise(t, report, nil, false) + + assert.Contains(t, got, "To fix, run these commands in order:") + assert.Contains(t, got, "1. azd provision") + assert.Contains(t, got, "2. azd deploy") + assert.NotRegexp(t, "(?m)3\\. azd provision", + "azd provision must be deduplicated even when multiple checks request it") + assert.Contains(t, got, "Then re-run `azd ai agent doctor` to verify.") +} + +// TestPrintDoctorReportText_ToFixBlockSuppressesNextOnFailure verifies the +// trailing "Next:" block is never emitted on a failed report (it would +// compete with the actionable "To fix" footer). +func TestPrintDoctorReportText_ToFixBlockSuppressesNextOnFailure(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "remote.agent-status", Name: "agents active", + Status: doctor.StatusFail, Message: "1 of 1 agents have not been deployed"}, + }, + Summary: doctor.Summary{Fail: 1}, + } + trailing := []nextstep.Suggestion{ + {Command: "azd ai agent run", Description: "should not render", Priority: 10}, + } + + got := renderConcise(t, report, trailing, true) + assert.Contains(t, got, "To fix, run these commands in order:") + assert.NotContains(t, got, "Next:") + assert.NotContains(t, got, "should not render") +} + +// TestWriteSummaryLine_WithWarnAndInfo pins the inverse contract: when +// warn/info counts are non-zero they MUST appear in the summary line. This +// guards against a regression that silently drops the segments. +func TestWriteSummaryLine_WithWarnAndInfo(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, writeSummaryLine(&buf, doctor.Summary{Pass: 2, Warn: 1, Info: 1})) + assert.Equal(t, "2 passed, 0 failed, 0 skipped, 1 warned, 1 info\n", buf.String()) +} + +// TestPrintDoctorReportText_ToFixBlockAllUnmappedFailures verifies the +// fallback footer when every failed check lacks a canonical remediation +// command in `remediationForCheckID`. The footer must still render a +// "To fix:" block (deferring to per-check `fix:` notes) and the re-run +// instruction so the user is never left without an actionable next step. +func TestPrintDoctorReportText_ToFixBlockAllUnmappedFailures(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "local.toolboxes", Name: "toolboxes resolvable", + Status: doctor.StatusFail, + Message: "failed to assemble agent state", + Suggestion: "Re-run `azd ai agent doctor` after fixing upstream errors."}, + }, + Summary: doctor.Summary{Fail: 1}, + } + + got := renderConcise(t, report, nil, false) + + assert.Contains(t, got, "To fix:", "unmapped-only failure still emits a footer header") + assert.NotContains(t, got, "To fix, run these commands in order:", + "unmapped-only failure must NOT promise a command list it cannot deliver") + assert.Contains(t, got, "Review the fix: notes above for each failed check.", + "unmapped-only failure points the user back to the per-check fix: lines") + assert.Contains(t, got, "Then re-run `azd ai agent doctor` to verify.", + "re-run instruction must always close the footer on failure") +} + +// TestPrintDoctorReportText_ToFixBlockMixedMappedAndUnmapped verifies that +// when at least one failure has a canonical remediation AND another failure +// is unmapped, the numbered command list is rendered AND a pointer to the +// per-check `fix:` notes is appended so the user knows the canonical commands +// are not exhaustive. +func TestPrintDoctorReportText_ToFixBlockMixedMappedAndUnmapped(t *testing.T) { + report := doctor.Report{ + Checks: []doctor.Result{ + {ID: "remote.foundry-endpoint", Name: "Foundry endpoint", + Status: doctor.StatusFail, Message: "endpoint unreachable"}, + {ID: "local.toolboxes", Name: "toolboxes resolvable", + Status: doctor.StatusFail, + Message: "failed to assemble agent state", + Suggestion: "Re-run `azd ai agent doctor` after fixing upstream errors."}, + }, + Summary: doctor.Summary{Fail: 2}, + } + + got := renderConcise(t, report, nil, false) + + assert.Contains(t, got, "To fix, run these commands in order:", + "mapped failure produces the numbered command list") + assert.Contains(t, got, "1. azd provision", "canonical command for foundry-endpoint") + assert.Contains(t, got, "Also review the fix: notes above for any remaining failed checks.", + "unmapped failure alongside mapped one appends a pointer to per-check fix: lines") + assert.Contains(t, got, "Then re-run `azd ai agent doctor` to verify.") +} + +// TestPrintDoctorReportText_EmptyReport verifies defensive behavior: a +// Report with no checks does not crash and surfaces a clear summary line. +func TestPrintDoctorReportText_EmptyReport(t *testing.T) { + got := renderConcise(t, doctor.Report{}, nil, false) + assert.Contains(t, got, "azd ai agent doctor") + assert.Contains(t, got, "No checks executed") +} + +// TestStatusGlyphAndLabel pins the per-status glyph contract; new format +// uses parenthesized indicators which are also exposed via statusGlyph. +func TestStatusGlyphAndLabel(t *testing.T) { + tests := []struct { + status doctor.Status + glyph string + label string + dataName string + }{ + {doctor.StatusPass, "(✓)", "PASS", "pass"}, + {doctor.StatusWarn, "(!)", "WARN", "warn"}, + {doctor.StatusFail, "(x)", "FAIL", "fail"}, + {doctor.StatusSkip, "(-)", "SKIP", "skip"}, + {doctor.StatusInfo, "(ⓘ)", "INFO", "info"}, + {doctor.Status("bogus"), "(?)", "UNKN", "unknown"}, + } + for _, tt := range tests { + t.Run(tt.dataName, func(t *testing.T) { + g, l := statusGlyphAndLabel(tt.status) + assert.Equal(t, tt.glyph, g) + assert.Equal(t, tt.label, l) + }) + } +} + +// TestCategoryForCheck pins the section-routing contract. +func TestCategoryForCheck(t *testing.T) { + tests := map[string]string{ + "local.grpc-extension": categoryLocal, + "local.azure-yaml": categoryLocal, + "remote.auth": categoryAuth, + "remote.foundry-endpoint": categoryRemote, + "remote.agent-status": categoryRemote, + "unknown.something": categoryRemote, // fallback bucket + "": categoryRemote, + } + for id, want := range tests { + t.Run(id, func(t *testing.T) { + assert.Equal(t, want, categoryForCheck(id)) + }) + } +} + +// TestCapitalize pins the capitalization helper contract: skip non-letter +// leads (numbers, env vars, backticks), idempotent on already-capitalized +// strings, and skip brand-name prefixes ("azd", "azure.yaml", "skipped:") +// that are conventionally lowercase in the rendered report. +// +//nolint:gosec // gosec G101: false positive on the "token acquisition failed" test fixture string. +func TestCapitalize(t *testing.T) { + tests := map[string]string{ + "": "", + "endpoint reachable": "Endpoint reachable", + "Endpoint reachable": "Endpoint reachable", + "AZURE_AI_PROJECT_ENDPOINT": "AZURE_AI_PROJECT_ENDPOINT", + "1 of 1 agents": "1 of 1 agents", + "`azure.ai.agent`": "`azure.ai.agent`", + // Brand-name leads stay lowercase. + "skipped: upstream blocked": "skipped: upstream blocked", + "azd extension reachable": "azd extension reachable", + "azure.yaml parsed": "azure.yaml parsed", + "agent.yaml valid for service": "agent.yaml valid for service", + // Generic lowercase leads do get capitalized. + "cancelled by user": "Cancelled by user", + "no manual env vars are missing": "No manual env vars are missing", + "failed to get project config": "Failed to get project config", + "token acquisition failed": "Token acquisition failed", + } + for in, want := range tests { + t.Run(in, func(t *testing.T) { + assert.Equal(t, want, capitalize(in)) + }) + } +} + +// TestFirstLine pins the first-line helper used to collapse multi-line +// Message/Suggestion strings in concise mode. +func TestFirstLine(t *testing.T) { + assert.Equal(t, "", firstLine("")) + assert.Equal(t, "hello", firstLine("hello")) + assert.Equal(t, "hello", firstLine("hello\nworld")) + assert.Equal(t, "hello", firstLine(" hello \nworld")) + assert.Equal(t, "", firstLine("\n\n")) +} + +// TestAnyServiceDeployed is unchanged from the previous suite; pinned for +// regression protection on the doctor's "Next:" trailing-block predicate. +func TestAnyServiceDeployed(t *testing.T) { + assert.False(t, anyServiceDeployed(nil)) + assert.False(t, anyServiceDeployed([]nextstep.ServiceState{})) + assert.False(t, anyServiceDeployed([]nextstep.ServiceState{ + {Name: "a", IsDeployed: false}, + {Name: "b", IsDeployed: false}, + })) + assert.True(t, anyServiceDeployed([]nextstep.ServiceState{ + {Name: "a", IsDeployed: false}, + {Name: "b", IsDeployed: true}, + })) + assert.True(t, anyServiceDeployed([]nextstep.ServiceState{ + {Name: "a", IsDeployed: true}, + })) +} + +// TestFilterDeployedServices is unchanged from the previous suite. +func TestFilterDeployedServices(t *testing.T) { + t.Run("nil state returns nil", func(t *testing.T) { + assert.Nil(t, filterDeployedServices(nil)) + }) + t.Run("filters out undeployed services", func(t *testing.T) { + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + {Name: "a", IsDeployed: true}, + {Name: "b", IsDeployed: false}, + {Name: "c", IsDeployed: true}, + }, + } + got := filterDeployedServices(state) + require.NotNil(t, got) + require.Len(t, got.Services, 2) + assert.Equal(t, "a", got.Services[0].Name) + assert.Equal(t, "c", got.Services[1].Name) + }) + t.Run("returns empty slice when none deployed", func(t *testing.T) { + state := &nextstep.State{ + Services: []nextstep.ServiceState{{Name: "a", IsDeployed: false}}, + } + got := filterDeployedServices(state) + require.NotNil(t, got) + assert.Empty(t, got.Services) + }) + t.Run("does not mutate input state", func(t *testing.T) { + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + {Name: "a", IsDeployed: true}, + {Name: "b", IsDeployed: false}, + }, + } + _ = filterDeployedServices(state) + assert.Len(t, state.Services, 2, "clone must not modify input") + }) +} + +// TestFilterDeployedServices_ChainedIntoResolveAfterDeploy is the end-to-end +// contract for doctor's post-deploy guidance block; preserved verbatim. +func TestFilterDeployedServices_ChainedIntoResolveAfterDeploy(t *testing.T) { + t.Parallel() + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + {Name: "alpha", IsDeployed: true, Protocol: nextstep.ProtocolResponses}, + {Name: "beta", IsDeployed: false, Protocol: nextstep.ProtocolResponses}, + }, + } + + out := nextstep.ResolveAfterDeploy(filterDeployedServices(state), nil, nil) + + require.Len(t, out, 2, "filtered state has one deployed service → show + invoke") + assert.Equal(t, "azd ai agent show alpha", out[0].Command, + "command must be service-qualified even when filtered list has len==1") + assert.Equal(t, `azd ai agent invoke alpha "Hello!"`, out[1].Command, + "invoke command must also be service-qualified") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index 50599221b42..017a8fd7951 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -11,6 +11,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "slices" @@ -20,11 +21,13 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/google/uuid" "go.yaml.in/yaml/v3" + "golang.org/x/term" ) const ( @@ -149,16 +152,15 @@ func saveContextValue( setContextValueSafe(ctx, azdClient, storeField, agentKey, value) } -// resolveLocalAgentKey builds the storage key for local mode from the azd project config. -// Returns the new structured key format: localhost://agents//versions/latest/local -func resolveLocalAgentKey(ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool) string { - return resolveLocalAgentKeyWithPort(ctx, azdClient, name, noPrompt, DefaultPort) -} - -// resolveLocalAgentKeyWithPort builds the local storage key with a specific port. -func resolveLocalAgentKeyWithPort( - ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool, port int, -) string { +// resolveLocalAgentName resolves the plain agent name used for local mode, +// without composing any port/project/version disambiguation into it. Use this +// when you need just a stable, file-system-safe identifier for the agent — +// for example, when naming the cached OpenAPI spec file shared with the +// `nextstep.ReadCachedOpenAPISpec` reader. +// +// For the structured config-store key (which DOES need port + project hash +// to avoid cross-project collisions), use `resolveLocalAgentKey` instead. +func resolveLocalAgentName(ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool) string { agentName := name if azdClient != nil { @@ -174,6 +176,20 @@ func resolveLocalAgentKeyWithPort( agentName = "local" } + return agentName +} + +// resolveLocalAgentKey builds the storage key for local mode from the azd project config. +// Returns the new structured key format: localhost://agents//versions/latest/local +func resolveLocalAgentKey(ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool) string { + return resolveLocalAgentKeyWithPort(ctx, azdClient, name, noPrompt, DefaultPort) +} + +// resolveLocalAgentKeyWithPort builds the local storage key with a specific port. +func resolveLocalAgentKeyWithPort( + ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool, port int, +) string { + agentName := resolveLocalAgentName(ctx, azdClient, name, noPrompt) projectPath := resolveProjectPath(ctx, azdClient) return buildLocalAgentKey(port, agentName, "", projectPath) } @@ -311,8 +327,16 @@ func captureResponseSession( // fetchOpenAPISpec fetches the OpenAPI spec from a running agent and caches it on disk. // baseURL is the root URL (e.g., "http://localhost:8088" or "{endpoint}/agents/{name}/endpoint/protocols"). // suffix is "local" or "remote", used in the cached filename. +// apiVersion, when non-empty, is appended as the "?api-version=" query parameter. +// Local agents do not require this; remote Foundry endpoints reject requests without it. // If forceRefresh is false and the file already exists, the fetch is skipped. -// Failures are non-fatal and silently ignored. +// +// Returns the on-disk path to the cached spec on success (whether freshly +// written or already cached), plus a fresh flag that is true only when this +// call actually wrote the file. Callers that want to surface the "OpenAPI +// spec saved to ..." line gate on the fresh flag; callers that just need the +// path (or want silence) ignore it. Returns ("", false) on any failure; +// errors are silently swallowed because the spec is best-effort. func fetchOpenAPISpec( ctx context.Context, azdClient *azdext.AzdClient, @@ -320,11 +344,12 @@ func fetchOpenAPISpec( agentName string, suffix string, bearerToken string, + apiVersion string, forceRefresh bool, -) { +) (string, bool) { configPath, err := resolveConfigPath(ctx, azdClient) if err != nil { - return + return "", false } configDir := filepath.Dir(configPath) @@ -337,14 +362,17 @@ func fetchOpenAPISpec( if !forceRefresh { if _, err := os.Stat(specFile); err == nil { - return // file exists, skip fetch + return specFile, false // already cached; surface the path without re-fetching } } specURL := baseURL + "/invocations/docs/openapi.json" + if apiVersion != "" { + specURL += "?api-version=" + url.QueryEscape(apiVersion) + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, specURL, nil) if err != nil { - return + return "", false } if bearerToken != "" { req.Header.Set("Authorization", "Bearer "+bearerToken) @@ -353,24 +381,24 @@ func fetchOpenAPISpec( client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) //nolint:gosec // G704: URL constructed from azd environment or localhost if err != nil { - return + return "", false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return + return "", false } body, err := io.ReadAll(resp.Body) if err != nil { - return + return "", false } if err := os.WriteFile(specFile, body, 0600); err != nil { - return + return "", false } - fmt.Printf("OpenAPI spec saved to %s\n", specFile) + return specFile, true } // resolveConversationID resolves a Foundry conversation ID. @@ -655,7 +683,14 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, return nil, err } - projectDir := filepath.Join(project.Path, svc.RelativePath) + projectDir, err := paths.JoinAllowRoot(project.Path, svc.RelativePath) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid service path for %s: %s", svc.Name, err), + "update azure.yaml so the agent service path stays within the project directory", + ) + } var startupCmd string if svc.Config != nil { @@ -740,9 +775,14 @@ func resolveAgentProtocol( ) } - agentYamlPath := filepath.Join( - project.Path, svc.RelativePath, "agent.yaml", - ) + agentYamlPath, err := paths.JoinAllowRoot(project.Path, svc.RelativePath, "agent.yaml") + if err != nil { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid service path for %s: %s", svc.Name, err), + "update azure.yaml so the agent service path stays within the project directory", + ) + } return protocolFromAgentYaml(agentYamlPath) } @@ -857,3 +897,10 @@ func multiProtocolError( ), ) } + +// isTerminal reports whether fd refers to an interactive terminal. +// Used to gate human-only output such as the next-step guidance block. +func isTerminal(fd uintptr) bool { + //nolint:gosec // file descriptors fit in int on all supported platforms + return term.IsTerminal(int(fd)) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go index cbbcb9c5c80..fcc825e4d31 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go @@ -341,3 +341,20 @@ func TestSetACREnvVar(t *testing.T) { }) } } + +func TestIsTerminal_NonTTY(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + t.Cleanup(func() { + _ = r.Close() + _ = w.Close() + }) + + if isTerminal(r.Fd()) { + t.Errorf("isTerminal(pipe read end) = true, want false") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 8a8a70c8e1a..68d9875476c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -21,10 +21,12 @@ import ( "strings" "time" + "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -1199,6 +1201,11 @@ func (a *InitAction) configureModelChoice( ); err != nil { return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } + if err := updatePendingProjectSignal( + ctx, a.azdClient, a.environment.Name, true, + ); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } } else { // Prompt user to pick an existing Foundry project or create new resources projectChoices := []*azdext.SelectChoice{ @@ -1251,6 +1258,11 @@ func (a *InitAction) configureModelChoice( ); err != nil { return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } + if err := updatePendingProjectSignal( + ctx, a.azdClient, a.environment.Name, false, + ); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } if err := ensureLocation(ctx, a.azdClient, a.azureContext, a.environment.Name); err != nil { return nil, err } @@ -1261,6 +1273,11 @@ func (a *InitAction) configureModelChoice( ); err != nil { return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } + if err := updatePendingProjectSignal( + ctx, a.azdClient, a.environment.Name, true, + ); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } } default: newCred, err := ensureSubscriptionAndLocation( @@ -1278,6 +1295,11 @@ func (a *InitAction) configureModelChoice( ); err != nil { return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } + if err := updatePendingProjectSignal( + ctx, a.azdClient, a.environment.Name, false, + ); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } } } @@ -1311,6 +1333,11 @@ func (a *InitAction) configureModelChoice( ); err != nil { return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } + if err := updatePendingProjectSignal( + ctx, a.azdClient, a.environment.Name, true, + ); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } } else { return nil, fmt.Errorf("specified foundry project was not found or is not eligible for the current configuration: %s", a.flags.projectResourceId) } @@ -1392,6 +1419,14 @@ func (a *InitAction) configureModelChoice( ); err != nil { return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) } + if err := updatePendingProjectSignal( + ctx, a.azdClient, a.environment.Name, false, + ); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + if err := ensureLocation(ctx, a.azdClient, a.azureContext, a.environment.Name); err != nil { + return nil, err + } } } @@ -2086,18 +2121,18 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa "\nAdded your agent as a service entry named '%s' under the file azure.yaml.\n", a.serviceNameOverride, ) - if projectID, _ := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: a.environment.Name, - Key: "AZURE_AI_PROJECT_ID", - }); projectID != nil && projectID.Value != "" && len(a.deploymentDetails) == 0 { - fmt.Printf("To deploy your agent, use %s.\n", - color.HiBlueString("azd deploy %s", a.serviceNameOverride)) - } else { - fmt.Printf( - "To provision and deploy the whole solution, use %s.\n", - color.HiBlueString("azd up"), - ) - } + + // Replace the legacy hardcoded `azd up` / `azd deploy` hint with the + // shared nextstep resolver. The resolver inspects the current azd + // environment plus each azure.ai.agent service's agent.yaml and emits + // context-aware guidance: `azd provision` when infra outputs are + // unset, `azd env set ` lines when agent.yaml references + // user-supplied variables that are unset, or `azd ai agent run` when + // everything is configured. All paths append the deploy hint as the + // trailing line. State-assembly errors are intentionally ignored: the + // resolver degrades gracefully on partial state per the design spec. + state, _ := nextstep.AssembleState(ctx, a.azdClient) + _ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state)) return nil } @@ -2861,7 +2896,7 @@ func injectToolboxEnvVarsIntoDefinition(manifest *agent_yaml.AgentManifest) erro } for _, tbName := range toolboxNames { - envKey := toolboxMCPEndpointEnvKey(tbName) + envKey := envkey.ToolboxMCPEndpoint(tbName) if existingNames[envKey] { return fmt.Errorf( "duplicate toolbox environment variable %q (from toolbox %q)", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 4b455ed0153..4082c5e8dad 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -9,6 +9,7 @@ import ( "azureaiagent/internal/project" "context" "fmt" + "log" "regexp" "slices" "strings" @@ -438,6 +439,13 @@ func configureAcrConnection( if err := setEnvValue(ctx, azdClient, envName, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", resourceId); err != nil { return err } + if err := updatePendingACRSignal(ctx, azdClient, envName, true); err != nil { + log.Printf("warning: failed to update acr provision signal: %v", err) + } + } else { + if err := updatePendingACRSignal(ctx, azdClient, envName, false); err != nil { + log.Printf("warning: failed to update acr provision signal: %v", err) + } } return nil } @@ -478,6 +486,9 @@ func configureAcrConnection( if err := setEnvValue(ctx, azdClient, envName, "AZURE_CONTAINER_REGISTRY_ENDPOINT", normalizeLoginServer(selectedConnection.Target)); err != nil { return err } + if err := updatePendingACRSignal(ctx, azdClient, envName, true); err != nil { + log.Printf("warning: failed to update acr provision signal: %v", err) + } return nil } @@ -528,6 +539,13 @@ func configureAppInsightsConnection( return err } } + if err := updatePendingAppInsightsSignal(ctx, azdClient, envName, true); err != nil { + log.Printf("warning: failed to update app_insights provision signal: %v", err) + } + } else { + if err := updatePendingAppInsightsSignal(ctx, azdClient, envName, false); err != nil { + log.Printf("warning: failed to update app_insights provision signal: %v", err) + } } return nil } @@ -571,6 +589,9 @@ func configureAppInsightsConnection( ); err != nil { return err } + if err := updatePendingAppInsightsSignal(ctx, azdClient, envName, true); err != nil { + log.Printf("warning: failed to update app_insights provision signal: %v", err) + } } return nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 1464b1eb709..127af877e0c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -4,6 +4,7 @@ package cmd import ( + "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" @@ -11,6 +12,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" posixpath "path" @@ -140,16 +142,21 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { validatePostInit(srcDir, localDefinition.CodeConfiguration) fmt.Println("\nYou can customize environment variables and other settings in the agent.yaml.") - if projectID, _ := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: a.environment.Name, - Key: "AZURE_AI_PROJECT_ID", - }); projectID != nil && projectID.Value != "" && !a.needsProvision { - fmt.Printf("Next steps: Run %s to deploy your agent to Microsoft Foundry.\n", - color.HiBlueString("azd deploy %s", localDefinition.Name)) - } else { - fmt.Printf("Next steps: Run %s to deploy your agent to Microsoft Foundry.\n", - color.HiBlueString("azd up")) - } + + // Delegate the trailing Next: block to the shared nextstep + // resolver — the same path used by the manifest-driven init + // flow (see InitAction.addToProject). The resolver inspects + // the current azd environment, the pending-provision signal, + // each agent.yaml's references to user-supplied variables, + // and emits context-aware guidance (`azd provision` when infra + // outputs are unset or pending, `azd env set ` lines when + // agent.yaml references unset user-supplied variables, or + // `azd ai agent run` when everything is configured). All paths + // terminate with the deploy hint. State-assembly errors are + // intentionally ignored: the resolver degrades gracefully on + // partial state per the design spec. + state, _ := nextstep.AssembleState(ctx, a.azdClient) + _ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state)) } return nil @@ -719,6 +726,16 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) if err := setEnvValue(ctx, a.azdClient, a.environment.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", existingDeployment.Name); err != nil { return nil, fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) } + + // Existing deployment chosen — clear any prior + // model_deployment tag so re-init that swaps from + // new-deployment back to existing doesn't leave the + // trailer stuck on `azd provision`. + if err := updatePendingModelDeploymentSignal( + ctx, a.azdClient, a.environment.Name, true, false, + ); err != nil { + log.Printf("warning: failed to update model_deployment provision signal: %v", err) + } } else if selectedModel != nil { modelDetails, err := a.resolveSelectedModelDeployment(ctx, selectedModel) if err != nil { @@ -747,6 +764,17 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) if err := setEnvValue(ctx, a.azdClient, a.environment.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", modelDetails.ModelName); err != nil { return nil, fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) } + + // New model deployment configured — record that the + // post-init trailer should suggest `azd provision`. See + // pending_provision.go for the lifecycle contract: this + // tag is cleared by postprovisionHandler after a + // successful provision. + if err := updatePendingModelDeploymentSignal( + ctx, a.azdClient, a.environment.Name, true, true, + ); err != nil { + log.Printf("warning: failed to update model_deployment provision signal: %v", err) + } } agentName, err = resolveExistingAgentNameConflict( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index a607d516cb4..dbc1a519f0a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "log" "slices" "strings" @@ -146,20 +147,37 @@ func (a *InitAction) selectFromList( return options[*resp.Value], nil } -func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_yaml.Model) (*project.Deployment, error) { +// getModelDeploymentDetails resolves the deployment for a single model +// referenced by the agent manifest. It returns the resolved +// project.Deployment alongside an `isNew` flag indicating whether the +// caller selected a not-yet-provisioned deployment (true) or an +// existing one already deployed in Azure (false). Callers use the +// flag to drive the AI_AGENT_PENDING_PROVISION signal that the +// post-init trailer and `azd ai agent doctor` consume — see +// pending_provision.go for the lifecycle contract. +// +// Flag semantics by branch: +// - matching existing deployment selected (line ~200) → isNew=false +// - "use_different" path picks an existing project deployment → isNew=false +// - "deploy_new" path, or fall-through after a no-match / no-deployment +// scenario, or no AZURE_AI_PROJECT_ID set (deploy-new init flow) +// → isNew=true +func (a *InitAction) getModelDeploymentDetails( + ctx context.Context, model agent_yaml.Model, +) (*project.Deployment, bool, error) { resp, err := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ EnvName: a.environment.Name, Key: "AZURE_AI_PROJECT_ID", }) if err != nil { - return nil, fmt.Errorf("failed to get the environment variable AZURE_AI_PROJECT_ID from your azd environment: %w", err) + return nil, false, fmt.Errorf("failed to get the environment variable AZURE_AI_PROJECT_ID from your azd environment: %w", err) } foundryProjectId := resp.Value if foundryProjectId != "" { parts := strings.Split(foundryProjectId, "/") if len(parts) < 9 { - return nil, fmt.Errorf( + return nil, false, fmt.Errorf( "invalid AZURE_AI_PROJECT_ID format: expected at least 9 path segments, got %d", len(parts)) } @@ -169,7 +187,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ allDeployments, err := listProjectDeployments(ctx, a.credential, subscription, resourceGroup, accountName) if err != nil { - return nil, fmt.Errorf("failed to list deployments: %w", err) + return nil, false, fmt.Errorf("failed to list deployments: %w", err) } matchingDeployments := make(map[string]*FoundryDeploymentInfo) @@ -191,7 +209,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ selection, err := a.selectFromList(ctx, "deployment", options, options[0]) if err != nil { - return nil, fmt.Errorf("failed to select deployment: %w", err) + return nil, false, fmt.Errorf("failed to select deployment: %w", err) } if selection != "Create new model deployment" { @@ -209,7 +227,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ Name: deployment.SkuName, Capacity: deployment.SkuCapacity, }, - }, nil + }, false, nil } } } else { @@ -239,9 +257,9 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ }) if err != nil { if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("model deployment selection was cancelled") + return nil, false, exterrors.Cancelled("model deployment selection was cancelled") } - return nil, fmt.Errorf("failed to prompt for no-match choice: %w", err) + return nil, false, fmt.Errorf("failed to prompt for no-match choice: %w", err) } if noMatchChoices[*noMatchResp.Value].Value == "use_different" { @@ -262,7 +280,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ selection, err := a.selectFromList(ctx, "deployment", deploymentOptions, deploymentOptions[0]) if err != nil { - return nil, fmt.Errorf("failed to select deployment: %w", err) + return nil, false, fmt.Errorf("failed to select deployment: %w", err) } if deployment, exists := deploymentMap[selection]; exists { @@ -278,7 +296,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ Name: deployment.SkuName, Capacity: deployment.SkuCapacity, }, - }, nil + }, false, nil } } } @@ -288,7 +306,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ modelDetails, err := a.getModelSelector().getModelDetails(ctx, model.Id) if err != nil { - return nil, fmt.Errorf("failed to get model details: %w", err) + return nil, false, fmt.Errorf("failed to get model details: %w", err) } message := fmt.Sprintf("Enter model deployment name for model '%s' (defaults to model name)", modelDetails.ModelName) @@ -301,7 +319,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ }, }) if err != nil { - return nil, fmt.Errorf("failed to prompt for text value: %w", err) + return nil, false, fmt.Errorf("failed to prompt for text value: %w", err) } modelDeployment := modelDeploymentInput.Value @@ -317,7 +335,7 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ Name: modelDetails.Sku.Name, Capacity: int(modelDetails.Capacity), }, - }, nil + }, true, nil } func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) (*azdext.AiModelDeployment, error) { @@ -824,6 +842,14 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age deploymentDetails := []project.Deployment{} paramValues := agent_yaml.ParameterValues{} + // anyModelProcessed tracks whether we encountered at least one + // model resource (so we know whether to call remove on the + // no-new-deployments path — a manifest with no models should not + // touch the pending-provision signal at all). anyNewDeployment + // flips true when getModelDeploymentDetails reports any tag-new + // branch was taken. + anyModelProcessed := false + anyNewDeployment := false switch agentDef.Kind { case agent_yaml.AgentKindHosted: for _, resource := range manifest.Resources { @@ -840,12 +866,16 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age if resourceDef.Kind == agent_yaml.ResourceKindModel { resource := resource.(agent_yaml.ModelResource) model := agent_yaml.Model{Id: resource.Id} - modelDeployment, err := a.getModelDeploymentDetails(ctx, model) + modelDeployment, isNew, err := a.getModelDeploymentDetails(ctx, model) if err != nil { return nil, nil, fmt.Errorf("failed to get model deployment details: %w", err) } deploymentDetails = append(deploymentDetails, *modelDeployment) paramValues[resource.Name] = modelDeployment.Name + anyModelProcessed = true + if isNew { + anyNewDeployment = true + } } } } @@ -862,6 +892,19 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age return nil, nil, fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) } + // Update the AI_AGENT_PENDING_PROVISION signal based on the + // aggregate of all model resources processed in this manifest. + // See updatePendingModelDeploymentSignal for the rule table. + // Errors writing the signal are logged but not returned: init's + // primary work (manifest processing) succeeded, and a transport + // failure on the hint signal should not abort it. The next init + // or provision run will reconcile. + if err := updatePendingModelDeploymentSignal( + ctx, a.azdClient, a.environment.Name, anyModelProcessed, anyNewDeployment, + ); err != nil { + log.Printf("warning: failed to update model_deployment provision signal: %v", err) + } + fmt.Println("Model deployment details processed and injected into agent definition. Deployment details can also be found in the JSON formatted AI_PROJECT_DEPLOYMENTS environment variable.") return updatedManifest, deploymentDetails, nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index c37af2b2eb2..927fe6b9358 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -1153,30 +1153,6 @@ func TestInjectToolboxEnvVarsIntoDefinition_NoopWithoutToolboxes(t *testing.T) { } } -func TestToolboxMCPEndpointEnvKey(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - expected string - }{ - {"simple", "my-tools", "TOOLBOX_MY_TOOLS_MCP_ENDPOINT"}, - {"spaces", "my tools", "TOOLBOX_MY_TOOLS_MCP_ENDPOINT"}, - {"mixed", "agent-tools v2", "TOOLBOX_AGENT_TOOLS_V2_MCP_ENDPOINT"}, - {"already upper", "TOOLS", "TOOLBOX_TOOLS_MCP_ENDPOINT"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := toolboxMCPEndpointEnvKey(tt.input) - if got != tt.expected { - t.Errorf("toolboxMCPEndpointEnvKey(%q) = %q, want %q", tt.input, got, tt.expected) - } - }) - } -} - func TestExtractConnectionConfigs_SurfacesCredentialsType(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 8cb962fecd1..c249d7ac380 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" @@ -337,6 +338,70 @@ func (a *InvokeAction) Run(ctx context.Context) error { return a.responsesRemote(ctx) } +// emitInvokeSuccessNextStep prints the resolver-driven Next: block after a +// successful invoke. Each of invoke's four success paths funnels through +// this helper so policy lives in `nextstep`, not in the command handler. +// +// State is intentionally nil: ResolveAfterInvoke's success branches don't +// inspect State (`resolver.go:resolveInvokeSuccess`), and the gRPC cost of +// AssembleState is wasted when the result isn't used. The failure helper +// below makes the same choice — see its doc for the resolver-side +// rationale that justifies skipping AssembleState even on failure today. +// +// Caller contract for `agentName`: pass the azure.yaml service name, NOT +// the deployed Foundry agent name. The resolver embeds this verbatim +// into the suggested `azd ai agent show `, and `show` keys on +// `s.Name` from azure.yaml (helpers.go:resolveAgentService). The remote +// invoke functions translate `name` in place from service name to +// Foundry name for the URL path; they MUST capture the service name +// separately and pass that here. See `responsesRemote` / +// `invocationsRemote` for the `serviceName` tracking pattern. The +// resolver-level contract is locked by +// TestResolveAfterInvoke_Success / "remote success with agent name → +// show + monitor" in `nextstep/resolver_test.go`. +// +// Output is gated on a TTY stdout per the nextstep call-site contract +// (`nextstep/types.go`, `nextstep/format.go`, `helpers.go:isTerminal`): +// the package never inspects TTY state, so callers must. Without the gate, +// piped or redirected stdout (`invoke > out.txt`, `invoke | tee log`, +// CI capture) would receive the human-only guidance block mixed in with +// the agent's reply. +func (a *InvokeAction) emitInvokeSuccessNextStep(mode nextstep.InvokeMode, agentName string) { + _ = printNextIfTerminal(os.Stdout, nextstep.ResolveAfterInvoke(nil, mode, agentName, nil)) +} + +// emitInvokeFailureNextStep prints the resolver-driven Next: block when +// an invoke fails. sessionCode is the value of the `x-adc-response-details` +// response header (or empty when the failure has no platform-classified +// session error code — e.g. local-server failures, connect errors, or +// any 4xx/5xx that didn't carry the header). Local-invoke failures pass +// the empty string and get a generic "see local server output" line per +// the resolver's InvokeLocal branch. +// +// State is intentionally nil with the same rationale as the success +// helper: today `resolveInvokeFailure(_ *State, mode, _ string, failure)` +// ignores State entirely (the `_` in the signature is load-bearing), and +// AssembleState costs an extra gRPC roundtrip the user pays for at the +// exact moment they're staring at an error message. If a future failure +// branch grows state-aware behavior, this is the single line to update. +// +// Output is TTY-gated for the same reason the success helper is — piped +// or redirected stdout must receive only the agent's reply (or the +// terminal error message via the host), never the human-only Next: block. +// +// Output ordering: the Next: block prints BEFORE the error message +// (which the host renders after this function returns). This is the +// "hint: ... error: ..." pattern git uses — acceptable for an +// interactive command, and avoids the sentinel-error / silent-stderr +// gymnastics that would be needed to flip the order. Revisit if user +// feedback says the block should print after the error. +func (a *InvokeAction) emitInvokeFailureNextStep(mode nextstep.InvokeMode, agentName, sessionCode string) { + failure := &nextstep.InvokeFailure{ + SessionCode: nextstep.SessionErrorCode(sessionCode), + } + _ = printNextIfTerminal(os.Stdout, nextstep.ResolveAfterInvoke(nil, mode, agentName, failure)) +} + // resolveProtocol returns the protocol to use for this invocation. // The explicit --protocol flag takes priority; otherwise the protocol // is auto-detected from agent.yaml (local or remote). @@ -475,6 +540,7 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { if traceID := responseTraceID(resp); traceID != "" { fmt.Printf("Trace ID: %s\n", traceID) } + a.emitInvokeFailureNextStep(nextstep.InvokeLocal, "", "") return fmt.Errorf( "POST %s failed with HTTP %d: %s\n%s", reqURL, resp.StatusCode, resp.Status, string(respBody), @@ -485,10 +551,15 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { if err := json.Unmarshal(respBody, &result); err != nil { // Not JSON — just print raw response fmt.Println(string(respBody)) + a.emitInvokeSuccessNextStep(nextstep.InvokeLocal, "") return nil } - return printAgentResponse(result, "local") + if err := printAgentResponse(result, "local"); err != nil { + return err + } + a.emitInvokeSuccessNextStep(nextstep.InvokeLocal, "") + return nil } // remoteContext holds the resolved inputs for a remote (Foundry) invoke. @@ -506,6 +577,7 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { // no-op. agentKey may still be non-empty in that case. type remoteContext struct { name string + serviceName string agentKey string projectEndpoint string apiVersion string @@ -514,6 +586,13 @@ type remoteContext struct { bearerToken string } +func (rc *remoteContext) nextStepName() string { + if rc.serviceName != "" { + return rc.serviceName + } + return rc.name +} + // resolveRemoteContext returns the inputs required to invoke a remote agent. // In project mode it opens an azd client and reads the environment; in ephemeral // mode (--agent-endpoint) it skips both. Auth token acquisition is intentionally @@ -546,8 +625,14 @@ func (a *InvokeAction) resolveRemoteContext(ctx context.Context) (*remoteContext rc.azdClient = azdClient rc.name = a.flags.name + // Auto-resolve agent name and version from azure.yaml. Track the + // azure.yaml service name separately from the deployed Foundry name + // so post-success next-step suggestions emit the service name; show + // keys on s.Name in azure.yaml and would 404 on the deployed Foundry + // name in the divergent case. if info, err := resolveAgentServiceFromProject(ctx, azdClient, rc.name, a.noPrompt); err == nil { - if rc.name == "" && info.AgentName != "" { + rc.serviceName = info.ServiceName + if info.AgentName != "" { rc.name = info.AgentName } if info.AgentEndpoint != "" { @@ -813,6 +898,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) + a.emitInvokeFailureNextStep(nextstep.InvokeRemote, rc.nextStepName(), resp.Header.Get("x-adc-response-details")) return fmt.Errorf("POST %s failed with HTTP %d: %s\n%s", respURL, resp.StatusCode, resp.Status, string(respBody)) } @@ -820,10 +906,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { if err := readSSEStream(resp.Body, rc.name); err != nil { return err } - - if agentKey != "" && rc.azdClient != nil { - fmt.Println("\n(tip: pass --new-session or --new-conversation to reset; see `azd ai agent invoke --help`)") - } + a.emitInvokeSuccessNextStep(nextstep.InvokeRemote, rc.nextStepName()) return nil } @@ -841,7 +924,19 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { defer azdClient.Close() } - agentKey := resolveLocalAgentKey(ctx, azdClient, a.flags.name, a.noPrompt) + // Resolve the agent service ONCE. The same plain name feeds both: + // - agentKey (composite, port + project + name) for the session + // and conversation store, where the wider scope is needed to + // avoid cross-project collisions in the shared config store. + // - agentName (plain) for the OpenAPI cache filename, which lives + // inside .azure// (already project-isolated) and must + // match `nextstep.ReadCachedOpenAPISpec`'s reader, which only + // knows the azure.yaml service name. + // Resolving twice would re-prompt the user on multi-agent projects + // AND risk picking different services for the two values (silent + // state corruption: session under A, cache under B). + agentName := resolveLocalAgentName(ctx, azdClient, a.flags.name, a.noPrompt) + agentKey := buildLocalAgentKey(DefaultPort, agentName, "", resolveProjectPath(ctx, azdClient)) // Resolve local session ID (generated locally, not server-assigned). var sid string @@ -863,7 +958,9 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { // Fetch and cache the agent's OpenAPI spec (always refresh for local). if azdClient != nil { - fetchOpenAPISpec(ctx, azdClient, localBaseURL, agentKey, "local", "", true) + if path, fresh := fetchOpenAPISpec(ctx, azdClient, localBaseURL, agentName, "local", "", "", true); fresh { + fmt.Printf("OpenAPI spec saved to %s\n", path) + } } invURL := localBaseURL + "/invocations" @@ -892,7 +989,15 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { fmt.Printf("Invocation: %s\n", invID) } - return handleInvocationResponse(ctx, resp, "", "", agentKey, a.httpTimeout(), nil) + if err := handleInvocationResponse(ctx, resp, "", "", agentKey, a.httpTimeout(), nil); err != nil { + // See invocationsRemote for the status-code rationale. + if resp.StatusCode >= 400 { + a.emitInvokeFailureNextStep(nextstep.InvokeLocal, agentName, "") + } + return err + } + a.emitInvokeSuccessNextStep(nextstep.InvokeLocal, agentName) + return nil } // invocationsRemote sends the user's message to Foundry using @@ -950,7 +1055,7 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { // mode (--agent-endpoint) we deliberately avoid the on-disk side effect since // the user is one-off targeting a remote endpoint. if rc.azdClient != nil && a.endpoint == nil { - fetchOpenAPISpec(ctx, rc.azdClient, remoteBaseURL, rc.name, "remote", rc.bearerToken, false) + fetchOpenAPISpec(ctx, rc.azdClient, remoteBaseURL, rc.name, "remote", rc.bearerToken, rc.apiVersion, false) } invURL := buildInvocationsURL(rc.projectEndpoint, rc.name, rc.apiVersion, sid) @@ -982,6 +1087,7 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, "Session: ") + sessionCode := resp.Header.Get("x-adc-response-details") if err := handleInvocationResponse( ctx, resp, @@ -991,12 +1097,19 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { a.httpTimeout(), a.flags.sessionRequestOptions(), ); err != nil { + // Only emit failure Next: for platform HTTP failures. + // 200 OK with an agent-error envelope (handleInvocationSync / + // handleInvocationSSE returning fmt.Errorf("agent error...")) is + // an agent-level error; the platform's SessionErrorCode vocabulary + // doesn't apply, and the responses protocol's equivalent + // (printAgentResponse / readSSEStream agent errors) is also + // not wired. Keeps the two protocols' UX consistent. + if resp.StatusCode >= 400 { + a.emitInvokeFailureNextStep(nextstep.InvokeRemote, rc.nextStepName(), sessionCode) + } return err } - - if agentKey != "" && rc.azdClient != nil { - fmt.Println("\n(tip: pass --new-session to reset; see `azd ai agent invoke --help`)") - } + a.emitInvokeSuccessNextStep(nextstep.InvokeRemote, rc.nextStepName()) return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index f031288787c..74e9b7f98e0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -10,14 +10,14 @@ import ( "log" "net/url" "os" - "path/filepath" - "regexp" "strings" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/envkey" + "azureaiagent/internal/pkg/paths" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" @@ -27,10 +27,6 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -// nonAlphanumEnvKeyRe matches any character that is not an uppercase letter or -// digit, used to sanitize environment variable key segments. -var nonAlphanumEnvKeyRe = regexp.MustCompile(`[^A-Z0-9]+`) - // configureExtensionHost wires the service target and event handlers on the // supplied [azdext.ExtensionHost]. It is passed to [azdext.NewListenCommand] // from the root command, which handles the surrounding setup (access token, @@ -81,10 +77,12 @@ func postprovisionHandler( azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs, ) error { + hasAgent := false for _, svc := range args.Project.Services { if svc.Host != AiAgentHost { continue } + hasAgent = true if err := provisionToolboxes(ctx, azdClient, svc); err != nil { return fmt.Errorf( @@ -94,9 +92,64 @@ func postprovisionHandler( } } + // Clear the AI_AGENT_PENDING_PROVISION signal now that provision has + // finished successfully. Init writes resource-class tags into this + // variable when it configures non-existent infra (a new model + // deployment, a new Foundry project, a blank ACR/AppInsights input) + // so the post-init trailer and `azd ai agent doctor` can recommend + // `azd provision`. Once provision returns success the signal is + // stale: subsequent runs of doctor/init/run/show/deploy should rely + // on the canonical post-provision env vars (AZURE_AI_PROJECT_ENDPOINT + // and friends) and the agent.yaml-vs-env diff. The clear is gated on + // the presence of at least one azure.ai.agent service so toolbox-only + // or non-agent provisions don't write to a variable they don't own. + // Best-effort: a transport failure here is logged but not returned — + // the user's provision DID succeed and surfacing a clear-time error + // would be confusing. The next init/doctor run will simply re-emit + // the suggestion until the variable is cleared by a future + // successful provision (or by the user via `azd env set ... ""`). + if hasAgent { + envName, err := currentEnvName(ctx, azdClient) + switch { + case err != nil: + log.Printf( + "warning: failed to look up current environment to clear %s: %v", + pendingProvisionEnvVar, err, + ) + case envName == "": + log.Printf( + "warning: no current environment selected; skipping clear of %s", + pendingProvisionEnvVar, + ) + default: + if clearErr := clearPendingProvisionReasons(ctx, azdClient, envName); clearErr != nil { + log.Printf( + "warning: failed to clear %s after provision: %v", + pendingProvisionEnvVar, clearErr, + ) + } + } + } + return nil } +// currentEnvName returns the name of the currently selected azd +// environment, or empty string + error when no environment is +// selected. Wraps Environment().GetCurrent so callers (notably +// postprovisionHandler) can read the current env name without +// duplicating the request shape. +func currentEnvName(ctx context.Context, azdClient *azdext.AzdClient) (string, error) { + resp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return "", err + } + if resp == nil || resp.Environment == nil { + return "", nil + } + return resp.Environment.Name, nil +} + func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { hasHostedAgent := false for _, svc := range args.Project.Services { @@ -127,7 +180,10 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az // isHostedAgentService checks if a service is a hosted (container) agent by reading // the agent.yaml kind from the service directory. func isHostedAgentService(svc *azdext.ServiceConfig, proj *azdext.ProjectConfig) bool { - agentYamlPath := filepath.Join(proj.Path, svc.RelativePath, "agent.yaml") + agentYamlPath, err := paths.JoinAllowRoot(proj.Path, svc.RelativePath, "agent.yaml") + if err != nil { + return false + } data, err := os.ReadFile(agentYamlPath) //nolint:gosec // path from azd project config if err != nil { return false @@ -348,9 +404,10 @@ func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azd } func kindEnvUpdate(ctx context.Context, azdClient *azdext.AzdClient, project *azdext.ProjectConfig, svc *azdext.ServiceConfig, envName string) error { - servicePath := svc.RelativePath - fullPath := filepath.Join(project.Path, servicePath) - agentYamlPath := filepath.Join(fullPath, "agent.yaml") + agentYamlPath, err := paths.JoinAllowRoot(project.Path, svc.RelativePath, "agent.yaml") + if err != nil { + return fmt.Errorf("invalid service path: %w", err) + } //nolint:gosec // agentYamlPath is resolved from project/service paths in current workspace data, err := os.ReadFile(agentYamlPath) @@ -730,7 +787,7 @@ func registerToolboxEnvVars( toolboxName string, toolboxVersion string, ) error { - envKey := toolboxMCPEndpointEnvKey(toolboxName) + envKey := envkey.ToolboxMCPEndpoint(toolboxName) endpoint := strings.TrimRight(projectEndpoint, "/") mcpEndpoint := fmt.Sprintf( @@ -743,13 +800,6 @@ func registerToolboxEnvVars( ) } -// toolboxMCPEndpointEnvKey builds the TOOLBOX_{NAME}_MCP_ENDPOINT env var key. -// Non-alphanumeric characters are replaced with underscores for a valid env key. -func toolboxMCPEndpointEnvKey(toolboxName string) string { - sanitized := nonAlphanumEnvKeyRe.ReplaceAllString(strings.ToUpper(toolboxName), "_") - return fmt.Sprintf("TOOLBOX_%s_MCP_ENDPOINT", sanitized) -} - // resolveToolboxEnvVars resolves ${VAR} references in toolbox name, description, // and all tool map values using the provided azd environment variables. func resolveToolboxEnvVars(toolbox *project.Toolbox, azdEnv map[string]string) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go index d50c3072ac2..919b52b955c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go @@ -4,6 +4,9 @@ package cmd import ( + "os" + "path/filepath" + "strings" "testing" "azureaiagent/internal/project" @@ -34,6 +37,56 @@ func TestPostdeployHandler_NoAgentService_NoOp(t *testing.T) { } } +func TestIsHostedAgentServiceRejectsTraversal(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + projectRoot := filepath.Join(parent, "project") + outside := filepath.Join(parent, "outside") + if err := os.MkdirAll(projectRoot, 0o750); err != nil { + t.Fatalf("failed to create project root: %v", err) + } + if err := os.MkdirAll(outside, 0o750); err != nil { + t.Fatalf("failed to create outside directory: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "agent.yaml"), []byte("kind: hostedAgent\n"), 0o600); err != nil { + t.Fatalf("failed to write outside agent.yaml: %v", err) + } + + svc := &azdext.ServiceConfig{Name: "echo", Host: AiAgentHost, RelativePath: "../outside"} + proj := &azdext.ProjectConfig{Path: projectRoot} + + if isHostedAgentService(svc, proj) { + t.Fatal("expected traversal service path to be rejected") + } +} + +func TestKindEnvUpdateRejectsTraversal(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + projectRoot := filepath.Join(parent, "project") + outside := filepath.Join(parent, "outside") + if err := os.MkdirAll(projectRoot, 0o750); err != nil { + t.Fatalf("failed to create project root: %v", err) + } + if err := os.MkdirAll(outside, 0o750); err != nil { + t.Fatalf("failed to create outside directory: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "agent.yaml"), []byte("kind: hostedAgent\n"), 0o600); err != nil { + t.Fatalf("failed to write outside agent.yaml: %v", err) + } + + svc := &azdext.ServiceConfig{Name: "echo", Host: AiAgentHost, RelativePath: "../outside"} + proj := &azdext.ProjectConfig{Path: projectRoot} + + err := kindEnvUpdate(t.Context(), nil, proj, svc, "dev") + + if err == nil || !strings.Contains(err.Error(), "invalid service path") { + t.Fatalf("expected invalid service path error, got: %v", err) + } +} + func TestParseConnectionIDs(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/error_codes.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/error_codes.go new file mode 100644 index 00000000000..cc392b6814d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/error_codes.go @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +// This file mirrors the wire-level error vocabulary emitted by the Foundry +// hosted-agents service so the extension can react without re-classifying. +// String values must stay byte-for-byte identical to the platform's +// `UserErrorCode`, `SessionErrorCode`, and `AgentVersionStatus` enums. +// +// Authoritative sources (vienna repo): +// - Services/HostedAgents/Common/Exceptions/UserErrorCode.cs +// - Services/HostedAgents/Session/Exceptions/SessionErrorCode.cs +// - Contracts/V2/Generated/Agents/AgentVersionStatus.cs +// +// The platform already appends `aka.ms/hostedagents/tsg/{image,code, +// provisioning,readme}` to user-facing deploy-failure messages via +// `WithTroubleshootingInfo`; the extension surfaces those messages +// verbatim and never re-derives a TSG link. + +// UserErrorCode is the platform's deploy-time error classification. +// Emitted on agent-version creation failures. +type UserErrorCode string + +const ( + // UserErrorImage covers ACR auth failures, unknown manifests, wrong + // architecture, DNS issues, and 403s on image pull. + UserErrorImage UserErrorCode = "ImageError" + // UserErrorCodeBlob covers code-blob 404s, ACL problems, and dependency + // resolution failures. + UserErrorCodeBlob UserErrorCode = "CodeError" + // UserErrorProvisioning is the catch-all for unclassified platform + // failures during agent version creation. + UserErrorProvisioning UserErrorCode = "ProvisioningError" +) + +// SessionErrorCode is the platform's invoke-time error classification. +// Surfaced on the `x-adc-response-details` response header (and inside +// the response body for some codes). +type SessionErrorCode string + +const ( + // SessionReadinessTimeout (HTTP 502 upstream): container was slow to + // bind its port. + SessionReadinessTimeout SessionErrorCode = "ReadinessTimeout" + // SessionProxyTimeout (HTTP 504 upstream): container hung mid-request. + SessionProxyTimeout SessionErrorCode = "ProxyTimeout" + // SessionSandboxIdle (HTTP 502 upstream + "not available" body): the + // session was paused for idleness and auto-resumes on retry. + SessionSandboxIdle SessionErrorCode = "SandboxIdle" + // SessionSandboxNotFound (HTTP 404 platform): the session was purged. + SessionSandboxNotFound SessionErrorCode = "SandboxNotFound" + // SessionQuotaExceeded (HTTP 429): per-subscription session quota hit. + SessionQuotaExceeded SessionErrorCode = "QuotaExceeded" + // SessionRegionalQuotaExceeded (HTTP 429): regional capacity full. + SessionRegionalQuotaExceeded SessionErrorCode = "RegionalQuotaExceeded" + // SessionAgentVersionNotReady: deploy is still in progress. + SessionAgentVersionNotReady SessionErrorCode = "AgentVersionNotReady" + // SessionAgentVersionProvisioningFailed: deploy failed; `show` surfaces + // the structured error. + SessionAgentVersionProvisioningFailed SessionErrorCode = "AgentVersionProvisioningFailed" +) + +// AgentVersionStatus mirrors the platform's lifecycle states for a +// deployed agent version. Wire values are lowercase — they match the +// serialization the Hosted Agents API returns in +// AgentVersionObject.Status (see pkg/agents/agent_api/models.go). +// Empirical verification: `azd ai agent show` returns "active" for a +// ready agent. The design-spec table uses title-case for readability +// only; the canonical surface is lowercase. +// +// One member of this type — AgentVersionIdle — is a defensive +// synonym not currently observed in the platform's verified enum; +// see its doc comment for rationale. +type AgentVersionStatus string + +const ( + // AgentVersionCreating indicates the deploy is still in progress. + AgentVersionCreating AgentVersionStatus = "creating" + // AgentVersionActive indicates the deploy succeeded and the agent is + // ready to receive invocations. + AgentVersionActive AgentVersionStatus = "active" + // AgentVersionIdle is a defensive synonym for "active" — the issue + // #7975 spec lists `idle` alongside `active` as a "ready" state + // (lines 208-209), although the platform's verified enum only emits + // `active` today. Treat any `idle` value the API may surface in the + // future the same as `active` (route to the invoke suggestion). + AgentVersionIdle AgentVersionStatus = "idle" + // AgentVersionFailed indicates the deploy failed; the error payload + // carries the structured reason. + AgentVersionFailed AgentVersionStatus = "failed" + // AgentVersionDeleting indicates a delete is in flight. + AgentVersionDeleting AgentVersionStatus = "deleting" + // AgentVersionDeleted indicates the version has been removed; a + // follow-up `azd deploy` is needed to redeploy. + AgentVersionDeleted AgentVersionStatus = "deleted" +) + +// RemediationForUserErrorCode returns the suggestion to surface alongside +// a deploy failure with the given UserErrorCode. The platform's message +// already includes the TSG URL, so callers should print the verbatim +// message above the returned suggestion line. +// +// Returns ok=false for unrecognized codes; callers should fall back to a +// generic "see `azd ai agent show` for the failure reason" line. +func RemediationForUserErrorCode(code UserErrorCode) (primary Suggestion, ok bool) { + switch code { + case UserErrorImage: + return Suggestion{ + Command: "azd ai agent monitor --type system --follow", + Description: "watch deploy logs for the image-pull failure", + }, true + case UserErrorCodeBlob: + return Suggestion{ + Command: "azd ai agent monitor --type system --follow", + Description: "watch deploy logs for the code-package failure", + }, true + case UserErrorProvisioning: + return Suggestion{ + Command: "azd ai agent show", + Description: "view the structured deploy error and follow the linked TSG", + }, true + } + return Suggestion{}, false +} + +// RemediationForSessionErrorCode returns the suggestion(s) to surface +// alongside an invoke failure with the given SessionErrorCode. Some codes +// produce a secondary action (e.g., quota-exceeded points to the +// session-list command); others return primary only with secondary nil. +// +// Returns ok=false for unrecognized codes; callers should fall back to +// "Run `azd ai agent monitor --tail 100` for container logs." +func RemediationForSessionErrorCode(code SessionErrorCode) (primary Suggestion, secondary *Suggestion, ok bool) { + switch code { + case SessionReadinessTimeout: + return Suggestion{ + Command: "azd ai agent invoke", + Description: "retry — the container was slow to bind its port", + }, + &Suggestion{ + Command: "azd ai agent monitor --type system", + Description: "check startup logs if retries continue to fail", + }, true + case SessionProxyTimeout: + return Suggestion{ + Command: "azd ai agent monitor --tail 100", + Description: "the container hung mid-request — inspect recent logs", + }, + nil, true + case SessionSandboxIdle: + return Suggestion{ + Command: "azd ai agent invoke", + Description: "retry — the session was paused and auto-resumes", + }, + nil, true + case SessionSandboxNotFound: + return Suggestion{ + Command: "azd ai agent invoke", + Description: "the previous session expired — retry to start a fresh one", + }, + nil, true + case SessionQuotaExceeded: + return Suggestion{ + Command: "azd ai agent session list", + Description: "session quota reached — delete unused sessions", + }, + nil, true + case SessionRegionalQuotaExceeded: + return Suggestion{ + Command: "azd provision", + Description: "regional capacity full — re-provision in a different region", + }, + nil, true + case SessionAgentVersionNotReady: + return Suggestion{ + Command: "azd ai agent show", + Description: "deploy still in progress — poll until status is Active", + }, + nil, true + case SessionAgentVersionProvisioningFailed: + return Suggestion{ + Command: "azd ai agent show", + Description: "deploy failed — view the structured error and linked TSG", + }, + nil, true + } + return Suggestion{}, nil, false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/error_codes_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/error_codes_test.go new file mode 100644 index 00000000000..e638636fd82 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/error_codes_test.go @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemediationForUserErrorCode(t *testing.T) { + t.Parallel() + + tests := []struct { + code UserErrorCode + expectOK bool + expectCmdHas string + }{ + {UserErrorImage, true, "azd ai agent monitor"}, + {UserErrorCodeBlob, true, "azd ai agent monitor"}, + {UserErrorProvisioning, true, "azd ai agent show"}, + {UserErrorCode("UnknownCode"), false, ""}, + {UserErrorCode(""), false, ""}, + } + + for _, tt := range tests { + t.Run(string(tt.code), func(t *testing.T) { + t.Parallel() + suggestion, ok := RemediationForUserErrorCode(tt.code) + assert.Equal(t, tt.expectOK, ok) + if tt.expectOK { + assert.Contains(t, suggestion.Command, tt.expectCmdHas) + assert.NotEmpty(t, suggestion.Description) + } else { + assert.Equal(t, Suggestion{}, suggestion) + } + }) + } +} + +func TestRemediationForSessionErrorCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code SessionErrorCode + expectOK bool + expectSecondary bool + expectPrimaryHas string + }{ + {"readiness timeout has secondary", SessionReadinessTimeout, true, true, "azd ai agent invoke"}, + {"proxy timeout has no secondary", SessionProxyTimeout, true, false, "azd ai agent monitor"}, + {"sandbox idle retry", SessionSandboxIdle, true, false, "azd ai agent invoke"}, + {"sandbox not found retry", SessionSandboxNotFound, true, false, "azd ai agent invoke"}, + {"quota exceeded lists sessions", SessionQuotaExceeded, true, false, "azd ai agent session list"}, + {"regional quota suggests provision", SessionRegionalQuotaExceeded, true, false, "azd provision"}, + {"agent version not ready polls show", SessionAgentVersionNotReady, true, false, "azd ai agent show"}, + {"version provisioning failed surfaces show", SessionAgentVersionProvisioningFailed, true, false, "azd ai agent show"}, + {"unknown code returns ok=false", SessionErrorCode("Bogus"), false, false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + primary, secondary, ok := RemediationForSessionErrorCode(tt.code) + require.Equal(t, tt.expectOK, ok) + if !tt.expectOK { + assert.Equal(t, Suggestion{}, primary) + assert.Nil(t, secondary) + return + } + assert.Contains(t, primary.Command, tt.expectPrimaryHas) + assert.NotEmpty(t, primary.Description) + if tt.expectSecondary { + require.NotNil(t, secondary) + assert.NotEmpty(t, secondary.Command) + assert.NotEmpty(t, secondary.Description) + } else { + assert.Nil(t, secondary) + } + }) + } +} + +// TestErrorCodeWireValues pins the string values to the platform contract. +// Any change here breaks the Foundry hosted-agents service compatibility. +func TestErrorCodeWireValues(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "ImageError": string(UserErrorImage), + "CodeError": string(UserErrorCodeBlob), + "ProvisioningError": string(UserErrorProvisioning), + "ReadinessTimeout": string(SessionReadinessTimeout), + "ProxyTimeout": string(SessionProxyTimeout), + "SandboxIdle": string(SessionSandboxIdle), + "SandboxNotFound": string(SessionSandboxNotFound), + "QuotaExceeded": string(SessionQuotaExceeded), + "RegionalQuotaExceeded": string(SessionRegionalQuotaExceeded), + "AgentVersionNotReady": string(SessionAgentVersionNotReady), + "AgentVersionProvisioningFailed": string(SessionAgentVersionProvisioningFailed), + "creating": string(AgentVersionCreating), + "active": string(AgentVersionActive), + "idle": string(AgentVersionIdle), + "failed": string(AgentVersionFailed), + "deleting": string(AgentVersionDeleting), + "deleted": string(AgentVersionDeleted), + } + + for expected, actual := range cases { + assert.Equal(t, expected, actual, "wire value drift") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format.go new file mode 100644 index 00000000000..d4b13a174b2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format.go @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "io" + "slices" + "strings" +) + +const ( + // primaryPrefix is the leading label of the first suggestion line. + primaryPrefix = "Next: " + // continuationPrefix indents subsequent lines so commands align under + // the first command. Width == len(primaryPrefix). + continuationPrefix = " " + // commandSeparator separates the (possibly padded) command from its + // description. Two-space gap + "-- " per the design spec. + commandSeparator = " -- " + // maxRendered caps the block at one primary + one optional secondary + // line ("more than two lines drowns out command output"). + maxRendered = 2 +) + +// PrintNext writes a "Next:" guidance block to w. Suggestions are sorted +// ascending by Priority (stable; ties preserve input order) and then +// truncated to a primary + optional secondary line. Empty input produces +// no output and no write. +// +// PrintNext does not inspect TTY state or output-format flags — those +// decisions live at the call site so the same renderer can serve both +// interactive stdout writes and string capture for tests / JSON envelopes. +// +// Use PrintAllNext when the resolver produces multiple REQUIRED follow-up +// actions (init / doctor fix-ups) where silently dropping any of them +// would mislead the user. +func PrintNext(w io.Writer, suggestions []Suggestion) error { + block := renderBlock(suggestions, maxRendered) + if block == "" { + return nil + } + _, err := io.WriteString(w, block) + return err +} + +// PrintAllNext writes a "Next:" guidance block to w like PrintNext but +// renders every suggestion (no two-line cap). Use this for flows where +// the suggestions are all REQUIRED follow-up actions rather than +// alternatives — the post-init flow can surface unresolved manifest +// placeholders, missing `azd env set` keys, AND the trailing +// `azd deploy` reminder simultaneously, and the user has to act on each +// one. Dropping any of them silently leaves the user thinking they are +// ready to deploy when they are not. +// +// Suggestions are still stable-sorted by Priority (ties preserve input +// order), the Trailing entry is still rendered last, and framing +// (leading blank line + trailing newline) matches PrintNext. Empty +// input is a no-op. +func PrintAllNext(w io.Writer, suggestions []Suggestion) error { + block := renderBlock(suggestions, 0) + if block == "" { + return nil + } + _, err := io.WriteString(w, block) + return err +} + +// FormatNextForNote renders a "Next:" block as a string suitable for +// embedding in an artifact's Metadata["note"]. Unlike PrintNext it does +// not truncate the block (the artifact note is a contained region, not +// interleaved with command output) and does not include a leading or +// trailing newline (the artifact renderer adds its own line break). +// +// Lines 2+ are pre-indented by 4 spaces so the command column stays +// aligned with line 1 when core azd's artifact renderer (which only +// indents the first line of the note) is called with the typical caller +// indent of two spaces — see cli/azd/pkg/project/artifact.go, which +// writes "\n%s %s" with the caller's indent on line 1 only. Under +// deeper or shallower caller indents the lines drift slightly but the +// note remains readable in both cases. +// +// Empty input returns an empty string. +func FormatNextForNote(suggestions []Suggestion) string { + body := renderRows(suggestions, 0) + if body == "" { + return "" + } + return strings.ReplaceAll(strings.TrimSuffix(body, "\n"), "\n", "\n ") +} + +// renderBlock returns the formatted "Next:" block (with a leading blank +// line and trailing newline) or an empty string when there is nothing to +// render. limit is forwarded to renderRows: a positive value caps the +// block at that many visible lines (PrintNext default), while limit <= 0 +// renders every suggestion (PrintAllNext). +func renderBlock(suggestions []Suggestion, limit int) string { + body := renderRows(suggestions, limit) + if body == "" { + return "" + } + // Leading blank line separates the block from preceding output. + return "\n" + body +} + +// renderRows returns the formatted suggestion lines (one per line, +// terminated with "\n") with no leading blank line. limit caps the +// number of visible suggestions; limit <= 0 means render every +// suggestion. +// +// Truncation is partitioned: at most one Suggestion.Trailing entry is +// reserved for the final visible slot, with remaining slots filled by +// primary (non-trailing) entries in ascending Priority order. The +// trailing reservation lets resolvers emit follow-up nudges (e.g., the +// post-action `azd deploy` line) without having those nudges silently +// dropped when primary suggestions outnumber the cap. +func renderRows(suggestions []Suggestion, limit int) string { + if len(suggestions) == 0 { + return "" + } + + sorted := slices.Clone(suggestions) + slices.SortStableFunc(sorted, func(a, b Suggestion) int { + return a.Priority - b.Priority + }) + + var primary []Suggestion + var trailing *Suggestion + for i := range sorted { + if sorted[i].Trailing { + // Always overwrite: because sorted is ascending by Priority, + // the last Trailing entry encountered has the highest + // Priority — i.e. the most-deferred footer wins on + // collision, defending the intended `azd deploy` slot from + // accidental lower-Priority Trailing flags. + trailing = &sorted[i] + continue + } + primary = append(primary, sorted[i]) + } + + var rendered []Suggestion + if limit > 0 && trailing != nil { + budget := max(limit-1, 0) + if len(primary) > budget { + primary = primary[:budget] + } + rendered = append(primary, *trailing) + } else if limit > 0 { + if len(primary) > limit { + primary = primary[:limit] + } + rendered = primary + } else { + rendered = primary + if trailing != nil { + rendered = append(rendered, *trailing) + } + } + + if len(rendered) == 0 { + return "" + } + + cmdWidth := 0 + for _, s := range rendered { + if n := len(s.Command); n > cmdWidth { + cmdWidth = n + } + } + + var b strings.Builder + for i, s := range rendered { + if i == 0 { + b.WriteString(primaryPrefix) + } else { + b.WriteString(continuationPrefix) + } + b.WriteString(s.Command) + if pad := cmdWidth - len(s.Command); pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } + b.WriteString(commandSeparator) + b.WriteString(s.Description) + b.WriteByte('\n') + } + return b.String() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format_test.go new file mode 100644 index 00000000000..f6a13652ab6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format_test.go @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrintNext(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + suggestions []Suggestion + want string + }{ + { + name: "empty input produces no output", + suggestions: nil, + want: "", + }, + { + name: "single suggestion renders one line with two-space gap", + suggestions: []Suggestion{ + {Command: "azd provision", Description: "set up Foundry"}, + }, + want: "\nNext: azd provision -- set up Foundry\n", + }, + { + name: "two suggestions align on longest command", + // Longest command "azd ai agent invoke 'hi'" is 24 chars. + // "azd ai agent show echo" (22) pads with 2 trailing spaces, then the + // two-space separator + "-- " (commandSeparator = " -- ") so the gap + // between "echo" and "--" totals 4 spaces; the second line has no pad + // so its gap is exactly the 2-space separator. + suggestions: []Suggestion{ + {Command: "azd ai agent show echo", Description: "verify status"}, + {Command: "azd ai agent invoke 'hi'", Description: "test it"}, + }, + want: "\n" + + "Next: azd ai agent show echo -- verify status\n" + + " azd ai agent invoke 'hi' -- test it\n", + }, + { + name: "more than two suggestions are truncated by priority", + suggestions: []Suggestion{ + {Command: "c", Description: "third", Priority: 30}, + {Command: "a", Description: "first", Priority: 10}, + {Command: "b", Description: "second", Priority: 20}, + }, + want: "\n" + + "Next: a -- first\n" + + " b -- second\n", + }, + { + name: "trailing suggestion survives truncation when primaries fill the block", + // Three primary suggestions would normally fill maxRendered (2) + // and drop the highest-priority trailing entry. The renderer + // must instead reserve the last slot for the Trailing footer so + // resolver-emitted follow-up nudges (e.g., `azd deploy`) are + // always visible. + suggestions: []Suggestion{ + {Command: "azd env set BAR ", Description: "supply BAR", Priority: 20}, + {Command: "azd env set FOO ", Description: "supply FOO", Priority: 21}, + {Command: "azd env set BAZ ", Description: "supply BAZ", Priority: 22}, + {Command: "azd deploy", Description: "when ready", Priority: 90, Trailing: true}, + }, + want: "\n" + + "Next: azd env set BAR -- supply BAR\n" + + " azd deploy -- when ready\n", + }, + { + name: "trailing-only block renders as the single line", + suggestions: []Suggestion{ + {Command: "azd deploy", Description: "when ready", Priority: 90, Trailing: true}, + }, + want: "\nNext: azd deploy -- when ready\n", + }, + { + name: "multiple Trailing entries collapse to the highest-priority one", + // Defensive: resolvers should emit at most one Trailing entry, + // but if more are passed in, only the highest-Priority one + // is rendered — the most-deferred footer wins, protecting + // the intended `azd deploy` slot from accidental + // lower-Priority Trailing flags. + suggestions: []Suggestion{ + {Command: "primary", Description: "primary", Priority: 10}, + {Command: "tail-a", Description: "tail a", Priority: 80, Trailing: true}, + {Command: "tail-b", Description: "tail b", Priority: 90, Trailing: true}, + }, + want: "\n" + + "Next: primary -- primary\n" + + " tail-b -- tail b\n", + }, + { + name: "stable sort preserves input order on equal priorities", + suggestions: []Suggestion{ + {Command: "first", Description: "f"}, + {Command: "second", Description: "s"}, + }, + want: "\n" + + "Next: first -- f\n" + + " second -- s\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, PrintNext(&buf, tt.suggestions)) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +// failingWriter returns an error on first Write; used to verify PrintNext +// propagates I/O errors from the underlying writer. +type failingWriter struct{} + +func (failingWriter) Write(_ []byte) (int, error) { + return 0, io.ErrUnexpectedEOF +} + +func TestPrintNext_PropagatesWriteError(t *testing.T) { + t.Parallel() + + err := PrintNext(failingWriter{}, []Suggestion{{Command: "x", Description: "y"}}) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) +} + +func TestPrintNext_EmptyInputSkipsWrite(t *testing.T) { + t.Parallel() + + // failingWriter would error if Write were called; nil suggestions + // must short-circuit before any write. + require.NoError(t, PrintNext(failingWriter{}, nil)) +} + +func TestPrintAllNext(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + suggestions []Suggestion + want string + }{ + { + name: "empty input produces no output", + suggestions: nil, + want: "", + }, + { + name: "single suggestion renders identically to PrintNext", + suggestions: []Suggestion{ + {Command: "azd provision", Description: "set up Foundry"}, + }, + want: "\nNext: azd provision -- set up Foundry\n", + }, + { + name: "G1 regression repro: placeholder + manual var + trailing deploy all render (no cap)", + // This is the toolbox-sample state that motivated commit 2194327e8. + // PrintNext (capped at 2 with trailing reservation) would render + // only [placeholder, deploy] and drop the env-set line, leaving + // the user thinking they only need to fix the placeholder before + // deploying. PrintAllNext must surface all three. + suggestions: []Suggestion{ + { + Command: "edit agent.yaml: replace {{TOOLBOX_ENDPOINT}} with the actual value", + Description: "agent.yaml has unresolved manifest placeholders", + Priority: 5, + }, + { + Command: "azd env set TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT ", + Description: "supply the agent.yaml variable", + Priority: 6, + }, + { + Command: "azd deploy", + Description: "when ready to deploy to Azure", + Priority: 90, + Trailing: true, + }, + }, + want: "\n" + + "Next: edit agent.yaml: replace {{TOOLBOX_ENDPOINT}} with the actual value -- agent.yaml has unresolved manifest placeholders\n" + + " azd env set TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT -- supply the agent.yaml variable\n" + + " azd deploy -- when ready to deploy to Azure\n", + }, + { + name: "renders well beyond maxRendered (3 placeholders + 3 manual vars + trailing = 7 lines)", + // Worst-case shape from ResolveAfterInit when both + // maxFixupLines caps are saturated. + suggestions: []Suggestion{ + {Command: "edit agent.yaml: replace {{A}} with the actual value", Description: "p1", Priority: 5}, + {Command: "edit agent.yaml: replace {{B}} with the actual value", Description: "p1", Priority: 6}, + {Command: "edit agent.yaml: replace {{C}} with the actual value", Description: "p1", Priority: 7}, + {Command: "azd env set FOO ", Description: "p2", Priority: 8}, + {Command: "azd env set BAR ", Description: "p2", Priority: 9}, + {Command: "azd env set BAZ ", Description: "p2", Priority: 10}, + {Command: "azd deploy", Description: "p3", Priority: 90, Trailing: true}, + }, + want: "\n" + + "Next: edit agent.yaml: replace {{A}} with the actual value -- p1\n" + + " edit agent.yaml: replace {{B}} with the actual value -- p1\n" + + " edit agent.yaml: replace {{C}} with the actual value -- p1\n" + + " azd env set FOO -- p2\n" + + " azd env set BAR -- p2\n" + + " azd env set BAZ -- p2\n" + + " azd deploy -- p3\n", + }, + { + name: "trailing entry still rendered last regardless of input order", + suggestions: []Suggestion{ + {Command: "azd deploy", Description: "when ready", Priority: 90, Trailing: true}, + {Command: "first", Description: "f", Priority: 5}, + {Command: "second", Description: "s", Priority: 6}, + }, + want: "\n" + + "Next: first -- f\n" + + " second -- s\n" + + " azd deploy -- when ready\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, PrintAllNext(&buf, tt.suggestions)) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestPrintAllNext_PropagatesWriteError(t *testing.T) { + t.Parallel() + + err := PrintAllNext(failingWriter{}, []Suggestion{{Command: "x", Description: "y"}}) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) +} + +func TestPrintAllNext_EmptyInputSkipsWrite(t *testing.T) { + t.Parallel() + + require.NoError(t, PrintAllNext(failingWriter{}, nil)) +} + +func TestFormatNextForNote(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + suggestions []Suggestion + want string + }{ + { + name: "empty input produces empty string", + suggestions: nil, + want: "", + }, + { + name: "single suggestion has no leading newline and no trailing newline", + suggestions: []Suggestion{ + {Command: "azd ai agent invoke 'hello'", Description: "send a test request", Priority: 10}, + }, + want: "Next: azd ai agent invoke 'hello' -- send a test request", + }, + { + name: "multi-line block pre-indents lines 2+ with 4 spaces", + suggestions: []Suggestion{ + {Command: "azd ai agent show", Description: "verify deployment", Priority: 10}, + {Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11}, + }, + want: "Next: azd ai agent show -- verify deployment\n" + + " azd ai agent invoke 'hi' -- send a request", + }, + { + name: "uncapped — third suggestion is preserved (unlike PrintNext)", + suggestions: []Suggestion{ + {Command: "azd ai agent show", Description: "verify deployment", Priority: 10}, + {Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11}, + {Command: "see ./agent/README.md", Description: "more sample requests", Priority: 12}, + }, + want: "Next: azd ai agent show -- verify deployment\n" + + " azd ai agent invoke 'hi' -- send a request\n" + + " see ./agent/README.md -- more sample requests", + }, + { + name: "trailing entry surfaces even when not the lowest priority", + suggestions: []Suggestion{ + {Command: "azd ai agent show", Description: "verify deployment", Priority: 10}, + {Command: "azd deploy", Description: "redeploy after changes", Priority: 90, Trailing: true}, + {Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11}, + }, + want: "Next: azd ai agent show -- verify deployment\n" + + " azd ai agent invoke 'hi' -- send a request\n" + + " azd deploy -- redeploy after changes", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := FormatNextForNote(tc.suggestions) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestFormatNextForNote_HostArtifactAlignment verifies the 4-space +// pre-indent matches the alignment core azd's artifact renderer produces +// when called with the typical caller indent (currentIndentation == " "). +// Core azd's artifact.go writes the note as: +// +// {indent}- {label}: ... +// {indent} {note} <- only line 1 of the note gets the +// indent+" " prefix; lines 2+ are +// flush-left in the output stream. +// +// FormatNextForNote pre-indents lines 2+ by 4 spaces, which equals +// indent(" ") + " " — i.e. the columns align so the rendered "Next:" +// header on line 1 sits directly above the continuation indent on line 2. +func TestFormatNextForNote_HostArtifactAlignment(t *testing.T) { + t.Parallel() + + note := FormatNextForNote([]Suggestion{ + {Command: "azd ai agent show", Description: "verify deployment", Priority: 10}, + {Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11}, + }) + + // Simulate core azd's render: " - label: location\n " + note + "\n". + const callerIndent = " " + rendered := callerIndent + "- endpoint: https://example/agents/foo/endpoint\n" + + callerIndent + " " + note + "\n" + + want := " - endpoint: https://example/agents/foo/endpoint\n" + + " Next: azd ai agent show -- verify deployment\n" + + " azd ai agent invoke 'hi' -- send a request\n" + assert.Equal(t, want, rendered) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/infra_outputs.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/infra_outputs.go new file mode 100644 index 00000000000..6450d511e4c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/infra_outputs.go @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "bufio" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "strings" +) + +// bicepOutputPattern matches a Bicep top-level `output` declaration header. +// Group 1 is the output name. The header form supported is: +// +// output = +// +// where is anything between the name and the `=` sign: +// a single identifier (`string`, `int`, `bool`, `object`, `array`), a +// parameterized type (`string[]`, `string?`), a dotted alias +// (`Microsoft.Storage`), a literal-type union (`'gpt-4o' | 'gpt-4.1'`), +// or a user-defined alias. We do not validate the type expression — we +// only need to identify that this line is an output and capture its +// name. Optional leading whitespace is allowed so that indented outputs +// inside conditional / loop blocks still match. Decorators such as +// `@description('…')` live on their own line and do not interfere +// because of the line anchor. +// +// Multi-line object/array literals are accepted because we only need the +// header — once the expression begins after `=` the parser walks past the +// remainder regardless of how many lines it spans. +// +// Bicep top-level `output` declarations are written by azd's Bicep +// provider to the environment dotenv verbatim, with no case conversion +// (see cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go around the +// `outputs[key] = ...` write and pkg/infra/provisioning/manager.go's +// `UpdateEnvironment` which calls `env.DotenvSet(key, ...)`). So the +// names captured here are exactly the env var names that `azd provision` +// will set in `.azure//.env`. +// +// Known limitations (acceptable for v1): +// - String literals are not parsed: if a Bicep `var x = 'literal /* +// ...'` opens a fake block comment, subsequent lines may be skipped. +// - Triple-quoted multi-line strings (`”'...”'`) are not tracked: +// embedding the literal text `output X Y = Z` inside one can produce +// a spurious capture. azd-generated templates do not exhibit either +// pattern in practice. +// - `module..outputs.` re-exports are not followed: only the +// outputs `main.bicep` itself declares are written to `.env` by azd. +var bicepOutputPattern = regexp.MustCompile(`^\s*output\s+([A-Za-z_][A-Za-z0-9_]*)\s+[^=]+=`) + +// bicepLineCommentPrefix is the only line-comment style Bicep supports +// (apart from `/* … */` block comments, which we handle separately). +const bicepLineCommentPrefix = "//" + +// discoverBicepOutputs returns the set of top-level `output` names declared +// in /infra/main.bicep, sorted ascending. The names are +// returned verbatim — no case conversion is performed because azd writes +// Bicep output names to `.env` verbatim (see bicepOutputPattern doc). +// +// All failure modes (missing file, missing directory, read error, malformed +// content) return a nil slice. The caller (detectMissingVars in state.go) +// treats an empty Bicep-output set as "no infra-classified vars" and +// routes every unresolved `${VAR}` reference into the manual-vars bucket. +// There is no prefix-based fallback; the deterministic Bicep-output set +// is the only source of truth, matching issue #7975 State Inputs line 74. +// +// We deliberately do not resolve `module..outputs.` re-exports: only +// outputs that the top-level `main.bicep` exposes are written to the +// environment file by azd. If a downstream module declares an output but +// it is not surfaced at the root, azd will not write it to `.env`. +// +// Block comments (`/* … */`) and single-line comments (`// …`) are +// stripped before matching so that a commented-out `// output foo …` is +// not picked up. Block comments are supported even when they span +// multiple lines. +func discoverBicepOutputs(projectPath string) []string { + if projectPath == "" { + return nil + } + + bicepPath := filepath.Join(projectPath, "infra", "main.bicep") + //nolint:gosec // G304: path is derived from the azd project root, not user input. + file, err := os.Open(bicepPath) + if err != nil { + // Best-effort: missing file or any read error returns an empty + // set. The caller treats an empty Bicep-output set as "no + // infra-classified vars" and routes every unresolved ${VAR} + // reference into the manual-vars bucket. We don't distinguish + // fs.ErrNotExist from permission / I/O errors here; the + // classifier deliberately does not block on Bicep parse + // problems. + return nil + } + defer file.Close() + + return parseBicepOutputs(file) +} + +// parseBicepOutputs walks the given reader line-by-line, stripping comments +// and matching the output header pattern. Split out from discoverBicepOutputs +// so tests can drive it without writing to disk. +func parseBicepOutputs(r io.Reader) []string { + scanner := bufio.NewScanner(r) + // main.bicep files in real templates routinely exceed bufio's default + // 64 KiB scanner line limit when single-line outputs reference deeply + // nested module expressions; raise the cap to 1 MiB so we do not + // silently miss outputs near the end of a long file. + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + seen := make(map[string]struct{}) + inBlockComment := false + + for scanner.Scan() { + line := scanner.Text() + + // Strip in-flight or starting block comments. We track block-comment + // state across lines because Bicep allows `/* … */` to span + // multiple lines. + stripped, stillInBlock := stripBicepComments(line, inBlockComment) + inBlockComment = stillInBlock + + // Skip the line if, after stripping, it begins with a line comment + // (very rare to see `// output foo …` but possible in templates). + trimmed := strings.TrimSpace(stripped) + if trimmed == "" || strings.HasPrefix(trimmed, bicepLineCommentPrefix) { + continue + } + + match := bicepOutputPattern.FindStringSubmatch(stripped) + if match == nil { + continue + } + seen[match[1]] = struct{}{} + } + // scanner.Err() (e.g., bufio.ErrTooLong on a >1 MiB line) is + // intentionally not surfaced: this is a best-effort classifier and a + // partial parse is more useful than nil. Any outputs successfully + // captured before a scan error still route to infra correctly, + // instead of silently falling back to the fully-manual default. + return sortedKeys(seen) +} + +func sortedKeys(set map[string]struct{}) []string { + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + slices.Sort(out) + return out +} + +// stripBicepComments removes `// …` line comments and `/* … */` block +// comments from a single line, given the inBlock state carried over from +// previous lines. Returns the stripped text and the new inBlock state. +// The implementation is intentionally simple: it does not honor comment +// markers that appear inside string literals because Bicep's comment +// syntax is restricted and the output-pattern regex requires a leading +// `output` keyword that cannot appear inside a string literal context +// before the `=` anyway. +func stripBicepComments(line string, inBlock bool) (string, bool) { + var b strings.Builder + i := 0 + for i < len(line) { + if inBlock { + // Look for the end of the block comment on this line. + end := strings.Index(line[i:], "*/") + if end < 0 { + // Block comment continues past end of line. + return b.String(), true + } + i += end + 2 + inBlock = false + continue + } + // Not in a block comment: look for the next comment opener. + if i+1 < len(line) { + pair := line[i : i+2] + if pair == "/*" { + inBlock = true + i += 2 + continue + } + if pair == bicepLineCommentPrefix { + // Rest of line is a line comment. + return b.String(), false + } + } + b.WriteByte(line[i]) + i++ + } + return b.String(), inBlock +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/infra_outputs_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/infra_outputs_test.go new file mode 100644 index 00000000000..ebc74d8752d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/infra_outputs_test.go @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseBicepOutputs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want []string + }{ + { + name: "empty input → nil", + in: "", + want: nil, + }, + { + name: "single output, common shape", + in: `param location string = resourceGroup().location + +output AZURE_AI_PROJECT_ENDPOINT string = aiProject.outputs.endpoint +`, + want: []string{"AZURE_AI_PROJECT_ENDPOINT"}, + }, + { + name: "multiple outputs in sorted order regardless of source order", + in: `output ZED string = '' +output ALPHA string = '' +output MIDDLE string = '' +`, + want: []string{"ALPHA", "MIDDLE", "ZED"}, + }, + { + name: "duplicate output names deduplicated", + in: `output FOO string = '' +output FOO string = '' +`, + want: []string{"FOO"}, + }, + { + name: "typed array output", + in: `output FOO string[] = []`, + want: []string{"FOO"}, + }, + { + name: "optional/nullable type output", + in: `output FOO string? = null`, + want: []string{"FOO"}, + }, + { + name: "dotted/qualified type output", + in: `output FOO Microsoft.Storage = bar`, + want: []string{"FOO"}, + }, + { + name: "literal-union type output", + in: `output FOO 'gpt-4o' | 'gpt-4.1' = 'gpt-4o'`, + want: []string{"FOO"}, + }, + { + name: "inferred-type output (no type token between name and =) rejected", + in: `output FOO = 'no-type'`, + want: nil, + }, + { + name: "ternary right-hand side accepted", + in: `output AZURE_AI_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId`, + want: []string{"AZURE_AI_PROJECT_ID"}, + }, + { + name: "indented output (inside a conditional block) accepted", + in: `if (condition) { + output INDENTED_OUTPUT string = 'value' +} +`, + want: []string{"INDENTED_OUTPUT"}, + }, + { + name: "underscore-prefixed name accepted", + in: `output _PRIVATE_THING string = ''`, + want: []string{"_PRIVATE_THING"}, + }, + { + name: "non-AZURE prefix output is captured (spec compliance)", + in: `output APPLICATIONINSIGHTS_CONNECTION_STRING string = '' +output BING_GROUNDING_CONNECTION_ID string = '' +output TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT string = '' +`, + want: []string{ + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "BING_GROUNDING_CONNECTION_ID", + "TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT", + }, + }, + { + name: "single-line comment masks output declaration", + in: `// output COMMENTED_OUT string = 'foo' +output REAL_ONE string = '' +`, + want: []string{"REAL_ONE"}, + }, + { + name: "trailing single-line comment after output declaration captured", + in: `output FOO string = '' // this is a comment`, + want: []string{"FOO"}, + }, + { + name: "block comment spanning multiple lines suppresses outputs inside", + in: `/* output HIDDEN_A string = '' +output HIDDEN_B string = '' +*/ +output VISIBLE string = '' +`, + want: []string{"VISIBLE"}, + }, + { + name: "block comment opening and closing on same line does not suppress later output on same line", + in: `/* hidden */ output SURFACE string = ''`, + want: []string{"SURFACE"}, + }, + { + name: "@description decorator on previous line does not interfere", + in: `@description('Project endpoint URL') +output AZURE_AI_PROJECT_ENDPOINT string = 'value' +`, + want: []string{"AZURE_AI_PROJECT_ENDPOINT"}, + }, + { + name: "param / var / resource keywords are ignored", + in: `param p string = '' +var v = 'x' +resource r 'Microsoft.Foo/bar@2024-01-01' = {} +output ACTUAL_OUTPUT string = '' +`, + want: []string{"ACTUAL_OUTPUT"}, + }, + { + name: "output keyword must be followed by name and type — bare 'output' line ignored", + in: `output +output ONLY_REAL_ONE string = '' +`, + want: []string{"ONLY_REAL_ONE"}, + }, + { + name: "no outputs → nil", + in: `param x string = '' +var y = x +`, + want: nil, + }, + { + name: "names starting with a digit are not valid Bicep identifiers and are not captured", + in: `output 9INVALID string = ''`, + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := parseBicepOutputs(strings.NewReader(tc.in)) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDiscoverBicepOutputs_MissingFileOrEmptyPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + projectPath string + }{ + {name: "empty projectPath returns nil", projectPath: ""}, + {name: "non-existent projectPath returns nil", projectPath: filepath.Join(t.TempDir(), "does-not-exist")}, + { + name: "projectPath without infra/main.bicep returns nil", + projectPath: t.TempDir(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, discoverBicepOutputs(tc.projectPath)) + }) + } +} + +func TestDiscoverBicepOutputs_RealFile(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + contents := `// Auto-generated header + +param location string = resourceGroup().location + +output AZURE_AI_PROJECT_ENDPOINT string = aiProject.outputs.endpoint +output TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT string = toolbox.outputs.mcpEndpoint + +/* output COMMENTED_OUT string = '' */ +output APPLICATIONINSIGHTS_CONNECTION_STRING string = appInsights.outputs.connectionString +` + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte(contents), + 0o600, + )) + + got := discoverBicepOutputs(projectRoot) + want := []string{ + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "AZURE_AI_PROJECT_ENDPOINT", + "TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT", + } + assert.Equal(t, want, got) +} + +func TestStripBicepComments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + inBlock bool + wantOut string + wantInBlock bool + }{ + { + name: "plain line passes through unchanged", + line: "output FOO string = ''", + wantOut: "output FOO string = ''", + }, + { + name: "line comment trims trailing text", + line: "output FOO string = '' // explanation", + wantOut: "output FOO string = '' ", + }, + { + name: "single-line block comment removed inline", + line: "output /* note */ FOO string = ''", + wantOut: "output FOO string = ''", + }, + { + name: "block comment opened but not closed flags inBlock", + line: "output /* eaten", + wantOut: "output ", + wantInBlock: true, + }, + { + name: "continuation of block comment with no closer keeps state", + line: "still inside the comment", + inBlock: true, + wantOut: "", + wantInBlock: true, + }, + { + name: "continuation of block comment with closer clears state", + line: "still inside */ output VISIBLE string = ''", + inBlock: true, + wantOut: " output VISIBLE string = ''", + wantInBlock: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, gotInBlock := stripBicepComments(tc.line, tc.inBlock) + assert.Equal(t, tc.wantOut, got) + assert.Equal(t, tc.wantInBlock, gotInBlock) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest.go new file mode 100644 index 00000000000..61128166ce5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest.go @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "cmp" + "os" + "slices" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" +) + +// manifestFileNames are the candidate manifest filenames the walker +// probes, in the same precedence order init / deploy paths use: +// agent.manifest.yaml wins over agent.manifest.yml. The non-manifest +// agent.yaml is deliberately NOT in this list — that file describes +// the running container (env vars, protocols) and never declares +// resources; mistakenly walking it would surface zero resources for +// every service that uses only agent.yaml (init-pending or +// agent.yaml-only templates). +var manifestFileNames = []string{ + "agent.manifest.yaml", + "agent.manifest.yml", +} + +// populateManifestResources walks each service's agent.manifest.yaml +// (when present) and aggregates the declared model/toolbox/connection +// resources onto state. The walker is strictly best-effort: missing +// files, unreadable bytes, malformed YAML, and unknown resource kinds +// are all silently skipped so an in-flight `azd ai agent init` (which +// rewrites the manifest mid-flight) or a template with no manifest +// (e.g., a bare agent.yaml) never blocks the rest of state assembly. +// +// Aggregation rules: +// +// - Has* flags are true when at least one resource of the matching +// kind is found across all services. +// - Slices are sorted by Name (ties broken by ServiceName) and the +// pair (ServiceName, Name) is the de-duplication key — the same +// name appearing under two services surfaces twice; the same name +// listed twice in one service collapses to one entry. This +// matches the doctor-check expectation that per-service failures +// remain individually addressable. +// - The Detail field carries a kind-specific summary (model id, +// connection target/category, empty for toolboxes) so doctor +// remediation lines have enough context to be actionable without +// re-parsing the manifest. +// +// Resource enumeration uses agent_yaml.ExtractResourceDefinitions +// directly (rather than LoadAndValidateAgentManifest) so a manifest +// with an absent / partial `template` block — common during init — +// still surfaces its `resources:` declarations. +func populateManifestResources(projectPath string, state *State) { + if state == nil || projectPath == "" || len(state.Services) == 0 { + return + } + + models := map[resourceKey]ResourceRef{} + toolboxes := map[resourceKey]ResourceRef{} + connections := map[resourceKey]ResourceRef{} + + for _, svc := range state.Services { + data := readManifestBytes(projectPath, svc.RelativePath) + if data == nil { + continue + } + resources, err := agent_yaml.ExtractResourceDefinitions(data) + if err != nil { + continue + } + for _, resource := range resources { + switch r := resource.(type) { + case agent_yaml.ModelResource: + if r.Name == "" { + continue + } + k := resourceKey{service: svc.Name, name: r.Name} + if _, dup := models[k]; dup { + continue + } + models[k] = ResourceRef{ + Name: r.Name, + ServiceName: svc.Name, + Detail: r.Id, + } + case agent_yaml.ToolboxResource: + if r.Name == "" { + continue + } + k := resourceKey{service: svc.Name, name: r.Name} + if _, dup := toolboxes[k]; dup { + continue + } + toolboxes[k] = ResourceRef{ + Name: r.Name, + ServiceName: svc.Name, + } + case agent_yaml.ConnectionResource: + if r.Name == "" { + continue + } + k := resourceKey{service: svc.Name, name: r.Name} + if _, dup := connections[k]; dup { + continue + } + connections[k] = ResourceRef{ + Name: r.Name, + ServiceName: svc.Name, + Detail: connectionDetail(r), + } + } + } + } + + state.ModelRefs = sortedResourceRefs(models) + state.Toolboxes = sortedResourceRefs(toolboxes) + state.Connections = sortedResourceRefs(connections) + state.HasModels = len(state.ModelRefs) > 0 + state.HasToolboxes = len(state.Toolboxes) > 0 + state.HasConnections = len(state.Connections) > 0 +} + +// readManifestBytes returns the first manifest file's contents under +// `//` (probing the names in +// manifestFileNames order) or nil if none exists / is readable. All +// failure modes — empty paths, missing directory, permission errors, +// truly empty file — return nil because every doctor / resolver +// consumer treats nil as "no manifest discovered for this service" +// and degrades gracefully. +func readManifestBytes(projectPath, relativePath string) []byte { + if projectPath == "" { + return nil + } + for _, name := range manifestFileNames { + manifestPath, err := paths.JoinAllowRoot(projectPath, relativePath, name) + if err != nil { + return nil + } + data, err := os.ReadFile(manifestPath) //nolint:gosec // path is validated under the project root + if err == nil && len(data) > 0 { + return data + } + } + return nil +} + +// connectionDetail renders the kind-specific identifier doctor +// remediation messages quote when a connection is missing or +// misconfigured. Empty-category and empty-target manifests fall back +// to whichever side is populated so we never emit a useless +// " | " separator with both halves blank. +func connectionDetail(r agent_yaml.ConnectionResource) string { + category := string(r.Category) + target := r.Target + switch { + case category != "" && target != "": + return category + " | " + target + case category != "": + return category + default: + return target + } +} + +// resourceKey is the (service, name) dedup key for the per-kind +// resource maps populated by populateManifestResources. Declared at +// package level so sortedResourceRefs can name the map type +// explicitly in its signature without a divergent anonymous-struct +// re-declaration. +type resourceKey struct { + service string + name string +} + +// sortedResourceRefs flattens the dedup map into a slice sorted by +// Name (ties broken by ServiceName). Callers consume the result by +// iterating in order, so the determinism is load-bearing for both +// doctor output snapshots and downstream display. +func sortedResourceRefs(m map[resourceKey]ResourceRef) []ResourceRef { + if len(m) == 0 { + return nil + } + out := make([]ResourceRef, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + slices.SortFunc(out, func(a, b ResourceRef) int { + if c := cmp.Compare(a.Name, b.Name); c != 0 { + return c + } + return cmp.Compare(a.ServiceName, b.ServiceName) + }) + return out +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest_test.go new file mode 100644 index 00000000000..2a9691c4da9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest_test.go @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const manifestThreeKinds = ` +template: + kind: containerAgent + name: hello +resources: + - name: gpt-4o + kind: model + id: azureml://registries/azure-openai/models/gpt-4o/versions/2024-08-06 + - name: web-search + kind: toolbox + tools: + - id: tool-1 + - name: bing-conn + kind: connection + category: BingLLMSearch + target: https://api.bing.microsoft.com/ + authType: ApiKey +` + +const manifestNoResources = ` +template: + kind: containerAgent + name: hello +` + +const manifestModelsOnly = ` +resources: + - name: gpt-4o-mini + kind: model + id: azureml://registries/azure-openai/models/gpt-4o-mini/versions/2024-07-18 +` + +// writeManifest writes data to //agent.manifest.yaml, +// creating intermediate directories as needed. +func writeManifest(t *testing.T, projectRoot, rel, data string) { + t.Helper() + dir := filepath.Join(projectRoot, rel) + require.NoError(t, os.MkdirAll(dir, 0o750)) + path := filepath.Join(dir, "agent.manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) +} + +func TestAssembleState_ManifestWalker_AllThreeKinds(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + writeManifest(t, projectRoot, "src/echo", manifestThreeKinds) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "src/echo"}, + }, + }, + } + + state, _ := assembleState(context.Background(), src) + + assert.True(t, state.HasModels) + assert.True(t, state.HasToolboxes) + assert.True(t, state.HasConnections) + + require.Len(t, state.ModelRefs, 1) + assert.Equal(t, "gpt-4o", state.ModelRefs[0].Name) + assert.Equal(t, "echo", state.ModelRefs[0].ServiceName) + assert.Contains(t, state.ModelRefs[0].Detail, "gpt-4o") + + require.Len(t, state.Toolboxes, 1) + assert.Equal(t, "web-search", state.Toolboxes[0].Name) + assert.Equal(t, "echo", state.Toolboxes[0].ServiceName) + assert.Empty(t, state.Toolboxes[0].Detail) + + require.Len(t, state.Connections, 1) + assert.Equal(t, "bing-conn", state.Connections[0].Name) + assert.Equal(t, "echo", state.Connections[0].ServiceName) + assert.Equal(t, "BingLLMSearch | https://api.bing.microsoft.com/", state.Connections[0].Detail) +} + +func TestAssembleState_ManifestWalker_RootRelativePath(t *testing.T) { + t.Parallel() + + for _, rel := range []string{"", "."} { + t.Run(fmt.Sprintf("rel=%q", rel), func(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + writeManifest(t, projectRoot, rel, manifestModelsOnly) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: rel}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + + require.Empty(t, errs) + assert.True(t, state.HasModels) + require.Len(t, state.ModelRefs, 1) + assert.Equal(t, "gpt-4o-mini", state.ModelRefs[0].Name) + }) + } +} + +func TestAssembleState_ManifestWalker_MissingManifestNoError(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + // Service exists in azure.yaml but its directory has no manifest file + // at all. Walker must degrade silently. + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "src/echo"), 0o750)) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "src/echo"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + + for _, err := range errs { + assert.NotContains(t, err.Error(), "manifest") + } + assert.False(t, state.HasModels) + assert.False(t, state.HasToolboxes) + assert.False(t, state.HasConnections) + assert.Nil(t, state.ModelRefs) + assert.Nil(t, state.Toolboxes) + assert.Nil(t, state.Connections) +} + +func TestAssembleState_ManifestWalker_RejectsTraversal(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + projectRoot := filepath.Join(parent, "project") + outside := filepath.Join(parent, "outside") + require.NoError(t, os.MkdirAll(projectRoot, 0o750)) + require.NoError(t, os.MkdirAll(outside, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "agent.manifest.yaml"), []byte(manifestThreeKinds), 0o600)) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "../outside"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + + require.Empty(t, errs) + assert.False(t, state.HasModels) + assert.False(t, state.HasToolboxes) + assert.False(t, state.HasConnections) +} + +func TestAssembleState_ManifestWalker_MalformedManifestNoError(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + writeManifest(t, projectRoot, "src/echo", "::: this is not valid yaml :::") + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "src/echo"}, + }, + }, + } + + state, _ := assembleState(context.Background(), src) + assert.False(t, state.HasModels) + assert.False(t, state.HasToolboxes) + assert.False(t, state.HasConnections) +} + +func TestAssembleState_ManifestWalker_NoResourcesKey(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + writeManifest(t, projectRoot, "src/echo", manifestNoResources) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "src/echo"}, + }, + }, + } + + state, _ := assembleState(context.Background(), src) + assert.False(t, state.HasModels) + assert.False(t, state.HasToolboxes) + assert.False(t, state.HasConnections) +} + +func TestAssembleState_ManifestWalker_AggregatesAcrossServices(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + writeManifest(t, projectRoot, "src/a", manifestModelsOnly) + writeManifest(t, projectRoot, "src/b", manifestThreeKinds) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "a": {Name: "a", Host: agentHost, RelativePath: "src/a"}, + "b": {Name: "b", Host: agentHost, RelativePath: "src/b"}, + }, + }, + } + + state, _ := assembleState(context.Background(), src) + assert.True(t, state.HasModels) + assert.True(t, state.HasToolboxes) + assert.True(t, state.HasConnections) + + require.Len(t, state.ModelRefs, 2) + // Sorted by Name ascending: gpt-4o (from "b") < gpt-4o-mini (from "a"). + assert.Equal(t, "gpt-4o", state.ModelRefs[0].Name) + assert.Equal(t, "b", state.ModelRefs[0].ServiceName) + assert.Equal(t, "gpt-4o-mini", state.ModelRefs[1].Name) + assert.Equal(t, "a", state.ModelRefs[1].ServiceName) +} + +func TestAssembleState_ManifestWalker_DedupSameServiceSameName(t *testing.T) { + t.Parallel() + + const dupManifest = ` +resources: + - name: gpt-4o + kind: model + id: first + - name: gpt-4o + kind: model + id: second +` + projectRoot := t.TempDir() + writeManifest(t, projectRoot, "src/echo", dupManifest) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "src/echo"}, + }, + }, + } + + state, _ := assembleState(context.Background(), src) + require.Len(t, state.ModelRefs, 1) + // First occurrence wins; subsequent dup is skipped silently. + assert.Equal(t, "first", state.ModelRefs[0].Detail) +} + +func TestAssembleState_ManifestWalker_PrefersYamlOverYml(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "src/echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "src/echo", "agent.manifest.yaml"), + []byte(manifestModelsOnly), + 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "src/echo", "agent.manifest.yml"), + []byte(manifestThreeKinds), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "src/echo"}, + }, + }, + } + + state, _ := assembleState(context.Background(), src) + // .yaml winner has models only, no toolboxes / connections. + assert.True(t, state.HasModels) + assert.False(t, state.HasToolboxes) + assert.False(t, state.HasConnections) + require.Len(t, state.ModelRefs, 1) + assert.Equal(t, "gpt-4o-mini", state.ModelRefs[0].Name) +} + +func TestAssembleState_ManifestWalker_IgnoresAgentYamlOnly(t *testing.T) { + t.Parallel() + + // agent.yaml (not agent.manifest.yaml) describes the container; it is + // not a manifest. The walker must NOT mistake it for one even when the + // content happens to parse: a service with only agent.yaml should + // surface no resources. + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "src/echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "src/echo", "agent.yaml"), + []byte(manifestThreeKinds), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "src/echo"}, + }, + }, + } + + state, _ := assembleState(context.Background(), src) + assert.False(t, state.HasModels) + assert.False(t, state.HasToolboxes) + assert.False(t, state.HasConnections) +} + +func TestConnectionDetail(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category string + target string + want string + }{ + {"both populated", "AzureOpenAI", "https://x.openai.azure.com/", "AzureOpenAI | https://x.openai.azure.com/"}, + {"only category", "AzureOpenAI", "", "AzureOpenAI"}, + {"only target", "", "https://x.openai.azure.com/", "https://x.openai.azure.com/"}, + {"both empty", "", "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := agent_yaml.ConnectionResource{ + Category: agent_yaml.CategoryKind(tc.category), + Target: tc.target, + } + assert.Equal(t, tc.want, connectionDetail(r)) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/openapi.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/openapi.go new file mode 100644 index 00000000000..3a8482e669f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/openapi.go @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ExtractInvokeExample returns a compact JSON-encoded sample payload from +// the agent's OpenAPI spec, suitable for use as the literal argument to +// `azd ai agent invoke ''`. The walk follows the resolution +// order documented in the design: +// +// 1. paths./invocations.post.requestBody.content.application/json.example +// 2. ...schema.example +// 3. Generated from schema.required + schema.properties[*].example +// 4. "" — caller falls back to the protocol-generic payload +// +// All errors are silent. The function never returns an error: if the +// spec is malformed, missing the /invocations path, or uses $ref for +// any of the above nodes, the result is "" and the caller uses the +// protocol-generic fallback. +func ExtractInvokeExample(spec []byte) string { + if len(spec) == 0 { + return "" + } + + var root map[string]any + if err := json.Unmarshal(spec, &root); err != nil { + return "" + } + + jsonContent := walkInvokeJSONContent(root) + if jsonContent == nil { + return "" + } + + if example, ok := jsonContent["example"]; ok { + if encoded, ok := encodeCompactJSON(example); ok { + return encoded + } + } + + schema, ok := jsonContent["schema"].(map[string]any) + if !ok { + return "" + } + + // $ref short-circuits the walk per the design's out-of-scope note. + if _, hasRef := schema["$ref"]; hasRef { + return "" + } + + if example, ok := schema["example"]; ok { + if encoded, ok := encodeCompactJSON(example); ok { + return encoded + } + } + + if payload, ok := payloadFromRequiredProperties(schema); ok { + if encoded, ok := encodeCompactJSON(payload); ok { + return encoded + } + } + + return "" +} + +// walkInvokeJSONContent returns the application/json content node under +// paths./invocations.post.requestBody.content, or nil on any miss. +func walkInvokeJSONContent(root map[string]any) map[string]any { + paths, ok := root["paths"].(map[string]any) + if !ok { + return nil + } + invocations, ok := paths["/invocations"].(map[string]any) + if !ok { + return nil + } + post, ok := invocations["post"].(map[string]any) + if !ok { + return nil + } + requestBody, ok := post["requestBody"].(map[string]any) + if !ok { + return nil + } + if _, hasRef := requestBody["$ref"]; hasRef { + return nil + } + content, ok := requestBody["content"].(map[string]any) + if !ok { + return nil + } + jsonContent, ok := content["application/json"].(map[string]any) + if !ok { + return nil + } + return jsonContent +} + +// payloadFromRequiredProperties builds a minimal object from the schema's +// `required` array and each required property's `example`. Properties +// without an example or with non-object property entries are skipped; +// if the result is empty, the second return is false. +func payloadFromRequiredProperties(schema map[string]any) (map[string]any, bool) { + required, ok := schema["required"].([]any) + if !ok || len(required) == 0 { + return nil, false + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + return nil, false + } + + out := make(map[string]any, len(required)) + for _, name := range required { + key, ok := name.(string) + if !ok || key == "" { + continue + } + prop, ok := properties[key].(map[string]any) + if !ok { + continue + } + example, ok := prop["example"] + if !ok { + continue + } + out[key] = example + } + if len(out) == 0 { + return nil, false + } + return out, true +} + +// encodeCompactJSON returns the compact JSON encoding of v with no +// trailing newline; functions/channels and other non-encodable values +// produce ok=false. +func encodeCompactJSON(v any) (string, bool) { + b, err := json.Marshal(v) + if err != nil { + return "", false + } + return string(b), true +} + +// ReadCachedOpenAPISpec returns the bytes of the on-disk OpenAPI cache +// produced by the extension's fetchOpenAPISpec helper for the given +// agent name and suffix ("local" or "remote"). Returns (nil, nil) when +// the file does not exist so callers can fall back without branching on +// the error type. +// +// configDir is the directory containing the active azd project's +// azure.yaml — the same directory fetchOpenAPISpec writes into. +// +// agentName is sanitized identically to fetchOpenAPISpec to keep the +// resolver and the writer in lockstep: any drift in the sanitization +// rule would let the resolver miss a freshly-cached spec. +func ReadCachedOpenAPISpec(configDir, agentName, suffix string) ([]byte, error) { + if configDir == "" || agentName == "" || suffix == "" { + return nil, fmt.Errorf("configDir, agentName, and suffix are required") + } + path := filepath.Join(configDir, fmt.Sprintf("openapi-%s-%s.json", sanitizeAgentName(agentName), suffix)) + bytes, err := os.ReadFile(path) //nolint:gosec // G304: configDir is the active project dir; agentName is sanitized + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + return bytes, nil +} + +// sanitizeAgentName mirrors the writer-side cleanup in fetchOpenAPISpec +// (cmd/helpers.go): strip path-traversal sequences and path separators +// so the resulting filename component stays inside configDir. +func sanitizeAgentName(name string) string { + safe := strings.ReplaceAll(name, "..", "_") + safe = strings.ReplaceAll(safe, "/", "_") + safe = strings.ReplaceAll(safe, "\\", "_") + return safe +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/openapi_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/openapi_test.go new file mode 100644 index 00000000000..99ca0a1a5a1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/openapi_test.go @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractInvokeExample(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + want string + }{ + { + name: "content-level example wins over schema", + spec: `{ + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "example": {"message": "hi"}, + "schema": { + "type": "object", + "example": {"never": "used"} + } + } + } + } + } + } + } + }`, + want: `{"message":"hi"}`, + }, + { + name: "schema-level example used when content example missing", + spec: `{ + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "example": {"q": "Hello"} + } + } + } + } + } + } + } + }`, + want: `{"q":"Hello"}`, + }, + { + name: "generated from required+properties[*].example", + spec: `{ + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["message", "tone"], + "properties": { + "message": {"type": "string", "example": "Hello"}, + "tone": {"type": "string", "example": "friendly"}, + "unused": {"type": "string", "example": "skip"} + } + } + } + } + } + } + } + } + }`, + want: `{"message":"Hello","tone":"friendly"}`, + }, + { + name: "$ref under requestBody returns empty (out of scope)", + spec: `{ + "paths": { + "/invocations": { + "post": { + "requestBody": {"$ref": "#/components/requestBodies/Invoke"} + } + } + } + }`, + want: "", + }, + { + name: "$ref under schema returns empty (out of scope)", + spec: `{ + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/InvokeRequest"} + } + } + } + } + } + } + }`, + want: "", + }, + { + name: "missing /invocations path returns empty", + spec: `{"paths": {"/health": {"get": {}}}}`, + want: "", + }, + { + name: "malformed JSON returns empty", + spec: `not json at all`, + want: "", + }, + { + name: "empty spec returns empty", + spec: ``, + want: "", + }, + { + name: "required without example produces empty", + spec: `{ + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["message"], + "properties": { + "message": {"type": "string"} + } + } + } + } + } + } + } + } + }`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ExtractInvokeExample([]byte(tt.spec)) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestReadCachedOpenAPISpec(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + specBytes := []byte(`{"openapi":"3.0.0"}`) + require.NoError(t, os.WriteFile(filepath.Join(dir, "openapi-echo-local.json"), specBytes, 0o600)) + + t.Run("returns bytes when file exists", func(t *testing.T) { + t.Parallel() + got, err := ReadCachedOpenAPISpec(dir, "echo", "local") + require.NoError(t, err) + assert.Equal(t, specBytes, got) + }) + + t.Run("missing file yields nil,nil (not an error)", func(t *testing.T) { + t.Parallel() + got, err := ReadCachedOpenAPISpec(dir, "echo", "remote") + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("path separators in agent name are sanitized to match writer", func(t *testing.T) { + t.Parallel() + // Writer (helpers.go fetchOpenAPISpec) replaces "/", "\", and ".." with "_". + require.NoError(t, os.WriteFile(filepath.Join(dir, "openapi-evil_x-local.json"), specBytes, 0o600)) + got, err := ReadCachedOpenAPISpec(dir, "evil/x", "local") + require.NoError(t, err) + assert.Equal(t, specBytes, got) + }) + + t.Run("missing inputs yield a typed error", func(t *testing.T) { + t.Parallel() + _, err := ReadCachedOpenAPISpec("", "echo", "local") + assert.Error(t, err) + _, err = ReadCachedOpenAPISpec(dir, "", "local") + assert.Error(t, err) + _, err = ReadCachedOpenAPISpec(dir, "echo", "") + assert.Error(t, err) + }) +} + +func TestSanitizeAgentName(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "echo": "echo", + "evil/path": "evil_path", + "evil\\path": "evil_path", + "../trav": "__trav", + "my agent": "my agent", + "a/b\\c/../d": "a_b_c___d", + } + for input, want := range cases { + assert.Equal(t, want, sanitizeAgentName(input), "input=%q", input) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go new file mode 100644 index 00000000000..20681e0df9c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -0,0 +1,628 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "fmt" + "path" + "slices" + "strings" +) + +// Default-payload literals used when the resolver cannot derive a sample +// payload from the agent's OpenAPI spec. Two protocols are recognized; +// anything else falls back to ProtocolDefaultPayload. +const ( + // ProtocolInvocations is the value of `agent.yaml#protocol` for + // JSON-body /invocations agents. + ProtocolInvocations = "invocations" + // ProtocolResponses is the value of `agent.yaml#protocol` for plain + // text /responses agents. + ProtocolResponses = "responses" + + invokeInvocationsPayload = `'{"message": "Hello!"}'` + invokeResponsesPayload = `"Hello!"` + + // maxFixupLines caps the number of `azd env set` / `edit agent.yaml` + // hints emitted by ResolveAfterInit per missing-input category so the + // block stays scannable even when an agent declares many manual + // variables or unresolved placeholders. + maxFixupLines = 3 +) + +// ResolveAfterInit produces the Next: block printed at the end of a +// successful `azd ai agent init`. Pure function over *State. +// +// Decision tree: +// - UnresolvedPlaceholders (always shown first when present, regardless +// of other branches) → one "edit agent.yaml: replace {{NAME}}" line +// per unresolved Mustache placeholder (up to maxFixupLines). These +// are deploy-time landmines: the literal `{{NAME}}` would otherwise +// land in the container. They never reach `azd env set` because the +// value lives in agent.yaml itself, not the azd environment. +// - len(PendingProvisionReasons) > 0 OR !HasProjectEndpoint OR +// MissingInfraVars → `azd provision` +// The project endpoint is the canonical "provision finished" +// marker — it is set by `azd provision` as a Bicep output, or by +// `azd ai agent init` when the user selects an existing Foundry +// project. When the endpoint is empty, provision has not yet +// populated the infra outputs (typical path: user selected +// "Deploy new models from the catalog" in init), so `azd +// provision` is the next step regardless of whether agent.yaml +// directly references any Bicep-output variables. +// MissingInfraVars is still consulted to cover the +// post-provision re-provision case (a new `${VAR}` reference +// mapping to a Bicep output was added to agent.yaml after the +// last provision run). PendingProvisionReasons is the explicit +// "init configured something provision still has to materialize" +// signal — every reason tag (project, model_deployment, acr, +// app_insights) fires this branch so a stale +// AZURE_AI_PROJECT_ENDPOINT carried over from a prior init or +// sibling environment cannot mislead the resolver into +// suggesting `azd ai agent run`. See state.PendingProvisionReasons +// for the env-var contract. +// - MissingManualVars → one `azd env set ` per missing var +// (up to maxFixupLines) plus an `azd ai agent run` follow-up so +// the user knows what to do after supplying the values. Matches +// issue #7975's "Then run 'azd ai agent run' to start locally" +// manual-vars example. The run follow-up is suppressed when +// UnresolvedPlaceholders are also present, since literal +// `{{NAME}}` values would still break the local agent. +// - Otherwise → `azd ai agent run` + `azd ai agent invoke +// --local ` secondary +// Spec: issue #7975 lines 96-103. The invoke-local secondary +// lets the user test the agent in another terminal once it's +// running. Payload is protocol-aware when the project has +// exactly one service in state (the unqualified `invoke --local` +// resolves to that service). For multi-agent projects the +// payload defaults to the responses-style `"Hello!"` and the +// command is left unqualified — the user picks the target at +// runtime via the interactive prompt or `--service` flag, the +// same shape the spec example uses. +// Both lines are skipped when only UnresolvedPlaceholders are +// present, because running locally with literal `{{NAME}}` +// values is broken. +// +// All paths append the static "When ready to deploy to Azure…" tail. +func ResolveAfterInit(state *State) []Suggestion { + if state == nil { + return nil + } + + out := make([]Suggestion, 0, 4) + priority := 5 + + // Placeholder fix-ups always come first when present: they are broken + // state in agent.yaml itself and block both `run` and `deploy`. The + // user has to edit agent.yaml (or define a matching parameter in + // agent.manifest.yaml) — `azd env set` cannot reach them. + hasPlaceholders := len(state.UnresolvedPlaceholders) > 0 + if hasPlaceholders { + placeholders := slices.Clone(state.UnresolvedPlaceholders) + slices.Sort(placeholders) + limit := min(len(placeholders), maxFixupLines) + for _, name := range placeholders[:limit] { + out = append(out, Suggestion{ + Command: fmt.Sprintf("edit agent.yaml: replace {{%s}} with the actual value", name), + Description: "agent.yaml has unresolved manifest placeholders", + Priority: priority, + }) + priority++ + } + } + + switch { + case len(state.PendingProvisionReasons) > 0 || + !state.HasProjectEndpoint || + len(state.MissingInfraVars) > 0: + out = append(out, Suggestion{ + Command: "azd provision", + Description: "set up your Foundry project, models, and connections", + Priority: priority, + }) + case len(state.MissingManualVars) > 0: + manual := slices.Clone(state.MissingManualVars) + slices.Sort(manual) + limit := min(len(manual), maxFixupLines) + for _, key := range manual[:limit] { + out = append(out, Suggestion{ + Command: fmt.Sprintf("azd env set %s ", key), + Description: "referenced by agent.yaml but not set in azd env", + Priority: priority, + }) + priority++ + } + // Follow-up: once the user supplies the values above, the next + // productive command is `azd ai agent run`. Without this hint + // the post-init Next: block stops at the env-set lines and the + // user has to remember the run step themselves — that's the + // "Then run 'azd ai agent run' to start locally" line in + // issue #7975's manual-vars example output. Suppressed when + // placeholders are also unresolved — running locally with + // literal `{{NAME}}` values produces a broken agent, so the + // user must finish the placeholder fix-ups first; the + // trailing `azd deploy` reminder still applies. + if !hasPlaceholders { + out = append(out, Suggestion{ + Command: "azd ai agent run", + Description: "start the agent locally once the values above are set", + Priority: priority, + }) + } + case hasPlaceholders: + // Only unresolved placeholders remain — do not emit + // `azd ai agent run` because running locally with literal + // `{{NAME}}` values produces a broken agent. The placeholder + // fix-ups above already tell the user what to do. + default: + out = append(out, Suggestion{ + Command: "azd ai agent run", + Description: "start the agent locally", + Priority: priority, + }) + priority++ + // Invoke-local secondary (issue #7975 lines 99-100). The + // spec's "everything ready" example shows the user a second + // command to try once the agent is running: + // azd ai agent invoke --local "Hello!" -- test it in another terminal + // Single-agent projects get a protocol-aware payload (matches + // the protocol the agent's `/invocations` or `/responses` + // endpoint expects). Multi-agent projects fall back to the + // responses-style "Hello!" literal because the unqualified + // command shape doesn't know which service the user will + // pick at runtime — mirroring the spec example which also + // uses the unqualified form. + invokePayload := invokeResponsesPayload + if len(state.Services) == 1 { + invokePayload = defaultInvokePayload(&state.Services[0]) + } + out = append(out, Suggestion{ + Command: fmt.Sprintf("azd ai agent invoke --local %s", invokePayload), + Description: "test it in another terminal", + Priority: priority, + }) + } + + out = append(out, Suggestion{ + Command: "azd deploy", + Description: "when ready to deploy to Azure", + Priority: 90, + Trailing: true, + }) + + return out +} + +// ResolveAfterRun produces the Next: block printed when the running +// agent first responds to its OpenAPI probe. Pure function over *State. +// +// Decision tree: +// - HasOpenAPI + OpenAPIPayload non-empty → invoke with extracted payload +// - ServiceState.Protocol == ProtocolInvocations → invoke with {"message"…} +// - Otherwise (ProtocolResponses or unknown) → invoke with "Hello!" +// +// When the resolver wanted a richer payload but could not extract one +// (HasOpenAPI=false), the Tip suggestion is appended so the user knows +// where to look up the agent's exact contract. +func ResolveAfterRun(state *State, serviceName string) []Suggestion { + if state == nil { + return nil + } + + svc := findService(state, serviceName) + payload := defaultInvokePayload(svc) + if state.HasOpenAPI && state.OpenAPIPayload != "" { + payload = shellEscapeSingleQuoted(state.OpenAPIPayload) + } + + out := []Suggestion{{ + Command: fmt.Sprintf("azd ai agent invoke --local %s", payload), + Description: "send a sample request to the running agent", + Priority: 10, + }} + + if !state.HasOpenAPI { + out = append(out, Suggestion{ + Command: "curl http://localhost:/invocations/docs/openapi.json", + Description: "tip: inspect the spec to learn the agent's exact payload", + Priority: 20, + }) + } + + return out +} + +// InvokeMode selects the invoke variant the user just ran. +type InvokeMode int + +const ( + // InvokeLocal is `azd ai agent invoke --local`. + InvokeLocal InvokeMode = iota + // InvokeRemote is the hosted-agent variant. + InvokeRemote +) + +// InvokeFailure describes a hosted-agent invoke failure for the resolver +// to branch on. SessionCode is the value of the `x-adc-response-details` +// header (or equivalent); empty means "not classified by the platform". +type InvokeFailure struct { + SessionCode SessionErrorCode +} + +// ResolveAfterInvoke produces the Next: block for a completed invoke. +// +// Success paths: +// - InvokeLocal → `azd deploy` + `azd ai agent monitor --follow` +// (the local invoke worked, so the next loop is "ship to Azure +// then watch the live logs". Spec: issue #7975 lines 168-181.) +// - InvokeRemote → `azd ai agent show ` + monitor secondary +// +// Failure paths: +// - InvokeLocal → single generic line ("see local server output") +// - InvokeRemote → branched on InvokeFailure.SessionCode via the +// error_codes vocabulary; unclassified failures get the monitor +// fallback. +func ResolveAfterInvoke(state *State, mode InvokeMode, agentName string, failure *InvokeFailure) []Suggestion { + if failure == nil { + return resolveInvokeSuccess(mode, agentName) + } + return resolveInvokeFailure(state, mode, agentName, failure) +} + +func resolveInvokeSuccess(mode InvokeMode, agentName string) []Suggestion { + if mode == InvokeLocal { + // Issue #7975 lines 168-181: local-invoke success has run to + // completion against a local `azd ai agent run` process, so + // the user has already provisioned (dependencies exist) and + // the agent code itself works. The natural next step is to + // ship to Azure with `azd deploy`, and once it's running + // there, `monitor --follow` is the live-log feed. + return []Suggestion{ + { + Command: "azd deploy", + Description: "deploy the agent to Azure", + Priority: 10, + }, + { + Command: "azd ai agent monitor --follow", + Description: "view logs after deploying", + Priority: 20, + }, + } + } + + primary := "azd ai agent show" + if agentName != "" { + primary = fmt.Sprintf("azd ai agent show %s", agentName) + } + return []Suggestion{ + { + Command: primary, + Description: "confirm the deployed agent is healthy", + Priority: 10, + }, + { + Command: "azd ai agent monitor --follow", + Description: "stream live logs from the agent", + Priority: 20, + }, + } +} + +func resolveInvokeFailure(_ *State, mode InvokeMode, _ string, failure *InvokeFailure) []Suggestion { + if mode == InvokeLocal { + return []Suggestion{{ + Command: "see local server output", + Description: "fix the error in your local agent and retry", + Priority: 10, + }} + } + + if failure.SessionCode == "" { + return []Suggestion{{ + Command: "azd ai agent monitor --tail 100", + Description: "inspect recent container logs for the failure", + Priority: 10, + }} + } + + primary, secondary, ok := RemediationForSessionErrorCode(failure.SessionCode) + if !ok { + return []Suggestion{{ + Command: "azd ai agent monitor --tail 100", + Description: fmt.Sprintf("session error %q — inspect recent logs", failure.SessionCode), + Priority: 10, + }} + } + + primary.Priority = 10 + out := []Suggestion{primary} + if secondary != nil { + s := *secondary + s.Priority = 20 + out = append(out, s) + } + return out +} + +// ResolveAfterShow produces the Next: block printed at the end of +// `azd ai agent show` for statuses that need action. Active agents +// intentionally return no guidance: `show` is an inspection command, +// and Doctor/deploy guidance already owns the "try invoke next" path. +// Branches on State.AgentStatus per the platform's `AgentVersionStatus` +// vocabulary. +// +// Status mapping (issue #7975 lines 208-214): +// - active / idle → no guidance (already healthy) +// - creating → `azd ai agent monitor --type system --follow` +// - failed / "" → `azd ai agent monitor --follow` (live log feed, +// used to be `--tail 100` pre-C5; spec calls for `--follow` so +// the user can watch the next reconcile attempt stream live) +// - deleting / deleted → `azd deploy` (redeploy) +// - anything else (transitional / genuinely unknown) → `azd ai agent +// show ` re-check +// +// serviceName is the azure.yaml service name used by the unknown-status +// `azd ai agent show ` re-check fallback. +func ResolveAfterShow(state *State, serviceName string) []Suggestion { + if state == nil { + return nil + } + + switch AgentVersionStatus(state.AgentStatus) { + case AgentVersionActive, AgentVersionIdle: + // `idle` is a defensive synonym for `active`. Both are healthy + // states, so `show` should stay a pure inspection command and + // not append invoke guidance. + return nil + case AgentVersionCreating: + return []Suggestion{{ + Command: "azd ai agent monitor --type system --follow", + Description: "deploy is still in progress — watch readiness", + Priority: 10, + }} + case AgentVersionFailed: + // Issue #7975 line 211: failed status maps to `monitor + // --follow`. The historical `--tail 100` was useful for + // one-shot CI inspection but the interactive default is the + // live tail — by the time `show` surfaces the failure, the + // user wants to watch the next reconcile attempt stream + // rather than capture a fixed-size window. + return []Suggestion{{ + Command: "azd ai agent monitor --follow", + Description: "stream agent logs to investigate the failure", + Priority: 10, + }} + case "": + // Issue #7975 line 210: empty status also routes to `monitor + // --follow`, but the framing differs from the Failed arm — + // here the platform simply hasn't reported a Status yet (the + // `show` table even suppresses the Status row in this case; + // see show.go printShowResultTable). The most useful next + // view is the live log feed, but we don't presume a failure + // occurred. + return []Suggestion{{ + Command: "azd ai agent monitor --follow", + Description: "stream agent logs — status has not been reported yet", + Priority: 10, + }} + case AgentVersionDeleting, AgentVersionDeleted: + return []Suggestion{{ + Command: "azd deploy", + Description: "redeploy the agent", + Priority: 10, + }} + } + + // Unknown / transitional / empty — re-check. + primary := "azd ai agent show" + if serviceName != "" { + primary = fmt.Sprintf("azd ai agent show %s", serviceName) + } + return []Suggestion{{ + Command: primary, + Description: "status is transitioning — re-check shortly", + Priority: 10, + }} +} + +// AfterDeployOpts configures ResolveAfterDeploy. Optional — the +// zero-value matches the post-deploy call site behavior. +type AfterDeployOpts struct { + // ForceQualified is retained for backward compatibility but is + // effectively a no-op as of issue #7975 fix B9: ResolveAfterDeploy + // now always emits service-qualified + // `azd ai agent show ` / `invoke ...` commands + // regardless of how many services are in state. + // + // Pre-B9 callers passed ForceQualified=true to override a + // "single-agent → unqualified command" heuristic that no longer + // exists. The flag is preserved so existing callers compile and + // run identically; new callers may simply omit it. + ForceQualified bool +} + +// ResolveAfterDeploy produces the Next: block embedded in the post-deploy +// artifact note. Issue #7975 fix B9 spec (lines 228-242): +// +// - Single-agent project: emit one `azd ai agent show ` line +// followed by one `azd ai agent invoke ''` line +// or, when no cached payload is available but a README exists, a +// README pointer followed by a placeholder invoke command. +// - Multi-agent project: emit all `show ` lines first (one +// per service, in declaration order), then all `invoke ` +// lines. Descriptions include the agent name — +// "verify is running" / "test " — so the user can +// identify which row maps to which agent at a glance. +// +// In both cases commands are always service-qualified (B9). Pre-B9 +// behavior would strip the name when len(state.Services) == 1, which +// produced ambiguous `azd ai agent show` lines in artifact notes that +// users couldn't run directly when copy-pasted into a multi-agent +// project later. The qualified form is unambiguous and copy-paste +// safe in either project shape. +// +// cachedPayload is injected by the caller (typically a closure over +// ReadCachedOpenAPISpec + ExtractInvokeExample) so the resolver itself +// stays pure and unit-testable. The cached sample is used verbatim +// (POSIX-escaped) when present. When no cached payload is available, +// services with a README get a README pointer first and an explicit +// '' placeholder instead of a concrete generic payload that +// may not match the agent's schema. +// +// readmeExists, also injected, controls whether the +// "See /README.md for a sample payload" line is emitted +// for a given service. The hint is emitted only when: +// (1) no cached payload was available for that service, +// (2) the service has a RelativePath, and +// (3) readmeExists reports a README on disk at that path. +// In the multi-agent layout each service's README hint is rendered +// immediately before that service's placeholder invoke line so users +// can find the sample-specific payload before running the command. +// +// opts is variadic for backward compatibility but is no longer +// consulted — every field of AfterDeployOpts is now a no-op post-B9. +// See AfterDeployOpts.ForceQualified for the historical context. +func ResolveAfterDeploy( + state *State, + cachedPayload func(serviceName string) string, + readmeExists func(relativePath string) bool, + opts ...AfterDeployOpts, +) []Suggestion { + if state == nil || len(state.Services) == 0 { + return nil + } + + singleAgent := len(state.Services) == 1 + out := make([]Suggestion, 0, len(state.Services)*3) + priority := 10 + + // Pass 1: all `azd ai agent show ` lines, in service order. + for _, svc := range state.Services { + desc := fmt.Sprintf("verify %s is running", svc.Name) + if singleAgent { + desc = "verify it's running" + } + out = append(out, Suggestion{ + Command: fmt.Sprintf("azd ai agent show %s", svc.Name), + Description: desc, + Priority: priority, + }) + priority++ + } + + // Pass 2: all `azd ai agent invoke ` lines, each + // preceded by its README hint when applicable. Grouping invokes + // after shows matches the spec example output (lines 238-241). + for _, svc := range state.Services { + payload := "" + if cachedPayload != nil { + payload = cachedPayload(svc.Name) + } + hasReadme := payload == "" && + readmeExists != nil && + readmeExists(svc.RelativePath) + + invokeArg := defaultInvokePayload(&svc) + if payload != "" { + invokeArg = shellEscapeSingleQuoted(payload) + } else if hasReadme { + invokeArg = "''" + } + + desc := fmt.Sprintf("test %s", svc.Name) + if singleAgent { + desc = "test the deployment" + } + + if payload == "" { + if hasReadme { + desc = fmt.Sprintf("test %s with the sample-specific payload", svc.Name) + if singleAgent { + desc = "test with the sample-specific payload" + } + out = append(out, Suggestion{ + Command: readmeCommand(svc.RelativePath), + Description: "find the sample-specific payload", + Priority: priority, + }) + priority++ + } else { + desc = fmt.Sprintf("test %s with a generic payload", svc.Name) + if singleAgent { + desc = "test with a generic payload" + } + } + } + + out = append(out, Suggestion{ + Command: fmt.Sprintf("azd ai agent invoke %s %s", svc.Name, invokeArg), + Description: desc, + Priority: priority, + }) + priority++ + } + + return out +} + +func readmeCommand(relativePath string) string { + rel := path.Clean(strings.ReplaceAll(relativePath, "\\", "/")) + if rel == "" || rel == "." { + return "see README.md" + } + rel = strings.TrimPrefix(rel, "./") + return fmt.Sprintf("see %s/README.md", rel) +} + +// findService returns a pointer to the named service in state, or nil. +// When serviceName is empty and there is exactly one service, that one is +// returned — handy for the single-agent default of `azd ai agent run`. +func findService(state *State, serviceName string) *ServiceState { + if state == nil { + return nil + } + if serviceName == "" { + if len(state.Services) == 1 { + return &state.Services[0] + } + return nil + } + for i := range state.Services { + if state.Services[i].Name == serviceName { + return &state.Services[i] + } + } + return nil +} + +// defaultInvokePayload returns the protocol-appropriate fallback payload +// string (already quoted) for a service. Unknown protocols and a nil +// service fall back to the /responses-style "Hello!" literal. +func defaultInvokePayload(svc *ServiceState) string { + if svc != nil && svc.Protocol == ProtocolInvocations { + return invokeInvocationsPayload + } + return invokeResponsesPayload +} + +// shellEscapeSingleQuoted wraps s in single quotes for POSIX shells. +// Each embedded apostrophe is replaced with the four-character POSIX +// escape sequence formed by: close the single-quoted string, emit a +// backslash-escaped literal apostrophe, then reopen. The implementation +// below uses a Go raw string for that sequence so its byte pattern is +// stable across edits. +// +// The extracted OpenAPI payload originates from json.Marshal, which +// does not escape apostrophes, so a sample like {"q":"don't"} would +// otherwise terminate the surrounding single-quoted argument and break +// the suggested command. +// +// PowerShell users running these suggestions must adapt the escape +// sequence manually — in PowerShell a literal apostrophe inside a +// single-quoted string is represented by two consecutive apostrophes +// instead. The suggestions are otherwise portable. +func shellEscapeSingleQuoted(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go new file mode 100644 index 00000000000..d9d9fd0265a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -0,0 +1,923 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveAfterInit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state *State + wantPrimaryHas string + wantManualVarKeys []string + wantTrailing string + }{ + { + name: "happy path (provisioned) → run locally", + state: &State{HasProjectEndpoint: true}, + wantPrimaryHas: "azd ai agent run", + wantTrailing: "azd deploy", + }, + { + name: "project endpoint not yet set → provision", + state: &State{}, + wantPrimaryHas: "azd provision", + wantTrailing: "azd deploy", + }, + { + name: "infra vars missing post-provision → provision (re-provision)", + state: &State{ + HasProjectEndpoint: true, + MissingInfraVars: []string{"AZURE_AI_FOO"}, + }, + wantPrimaryHas: "azd provision", + wantTrailing: "azd deploy", + }, + { + name: "manual vars missing → up to 3 env set lines, sorted", + state: &State{ + HasProjectEndpoint: true, + MissingManualVars: []string{"DELTA", "ALPHA", "ECHO", "BRAVO"}, + }, + wantManualVarKeys: []string{"ALPHA", "BRAVO", "DELTA"}, + wantTrailing: "azd deploy", + }, + { + name: "project endpoint missing wins over manual vars (provision unblocks both)", + state: &State{ + MissingManualVars: []string{"USER_API_KEY"}, + }, + wantPrimaryHas: "azd provision", + wantTrailing: "azd deploy", + }, + { + // User selected "Deploy new model(s)" in init. The Foundry + // project does not exist yet, but a stale + // AZURE_AI_PROJECT_ENDPOINT carried over from a prior init + // or sibling environment sets HasProjectEndpoint=true. + // Without the explicit "project" pending-provision tag + // the resolver would default to `azd ai agent run` and + // mislead the user into running a local invoke against a + // project that has not been provisioned. + name: "deploy-new chosen but stale endpoint → provision (override)", + state: &State{ + HasProjectEndpoint: true, + PendingProvisionReasons: []string{"project"}, + }, + wantPrimaryHas: "azd provision", + wantTrailing: "azd deploy", + }, + { + // Existing-project init path. USE_EXISTING_AI_PROJECT=true + // in the env var leaves PendingProvisionReasons empty at + // state assembly, so the legacy heuristic continues to + // drive: endpoint set + no missing vars ⇒ `azd ai agent + // run`. This case locks the no-regression contract for + // the existing path. + name: "existing project chosen, all vars set → run locally (no override)", + state: &State{ + HasProjectEndpoint: true, + }, + wantPrimaryHas: "azd ai agent run", + wantTrailing: "azd deploy", + }, + { + // Init configured a new model deployment in an existing + // Foundry project: HasProjectEndpoint=true (existing + // project), but PendingProvisionReasons contains + // "model_deployment". The resolver must still suggest + // `azd provision` so Bicep creates the new deployment. + name: "new model deployment in existing project → provision (PendingProvisionReasons override)", + state: &State{ + HasProjectEndpoint: true, + PendingProvisionReasons: []string{"model_deployment"}, + }, + wantPrimaryHas: "azd provision", + wantTrailing: "azd deploy", + }, + { + // Multiple pending reasons collected during init — + // e.g. user left ACR blank and configured a new model. + // Still single `azd provision` suggestion (resolver + // treats the list as opaque non-emptiness). + name: "multiple pending reasons → single provision suggestion", + state: &State{ + HasProjectEndpoint: true, + PendingProvisionReasons: []string{"acr", "model_deployment"}, + }, + wantPrimaryHas: "azd provision", + wantTrailing: "azd deploy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + out := ResolveAfterInit(tt.state) + require.NotEmpty(t, out) + + // The trailing line is always present and flagged Trailing so + // the renderer reserves a slot for it during truncation. + last := out[len(out)-1] + assert.Equal(t, tt.wantTrailing, last.Command) + assert.True(t, last.Trailing, "last suggestion must be flagged Trailing") + + if len(tt.wantManualVarKeys) > 0 { + // N env-set lines + 1 `azd ai agent run` follow-up + 1 + // trailing `azd deploy`. + assert.Len(t, out, len(tt.wantManualVarKeys)+2) + for i, key := range tt.wantManualVarKeys { + assert.True(t, + strings.HasPrefix(out[i].Command, "azd env set "+key+" "), + "got %q", out[i].Command) + } + // The slot immediately after the env-set lines is the + // run follow-up — see ResolveAfterInit's MissingManualVars + // branch (issue #7975 manual-vars example). + followUp := out[len(tt.wantManualVarKeys)] + assert.Equal(t, "azd ai agent run", followUp.Command, + "expected `azd ai agent run` follow-up after env-set lines") + assert.False(t, followUp.Trailing, + "run follow-up must be a primary suggestion, not Trailing") + } else { + assert.Contains(t, out[0].Command, tt.wantPrimaryHas) + } + }) + } +} + +func TestResolveAfterInit_ManualVarsCapAtThree(t *testing.T) { + t.Parallel() + + state := &State{ + HasProjectEndpoint: true, + MissingManualVars: []string{"V1", "V2", "V3", "V4", "V5"}, + } + out := ResolveAfterInit(state) + // 3 env-set lines (capped) + 1 `azd ai agent run` follow-up + 1 + // trailing `azd deploy`. + require.Len(t, out, 5) + for i := range 3 { + assert.True(t, strings.HasPrefix(out[i].Command, "azd env set "), + "slot %d should be an env-set line, got %q", i, out[i].Command) + } + assert.Equal(t, "azd ai agent run", out[3].Command, + "slot 3 should be the run follow-up") + assert.Equal(t, "azd deploy", out[4].Command) + assert.True(t, out[4].Trailing, "deploy footer must be Trailing") +} + +func TestResolveAfterInit_NilState(t *testing.T) { + t.Parallel() + assert.Nil(t, ResolveAfterInit(nil)) +} + +// TestResolveAfterInit_ManualVarsSingleEmitsEnrichedShape locks the +// single-missing-manual-var case end-to-end. Three asserts: the env-set +// line has the enriched "referenced by agent.yaml but not set in azd +// env" description, the `azd ai agent run` follow-up immediately follows +// the env-set lines, and the trailing `azd deploy` reminder is preserved. +// This is the canonical B2 fix shape from issue #7975's "Example output +// (project ready, but manual config values missing)". +func TestResolveAfterInit_ManualVarsSingleEmitsEnrichedShape(t *testing.T) { + t.Parallel() + + state := &State{ + HasProjectEndpoint: true, + MissingManualVars: []string{"MY_API_KEY"}, + } + out := ResolveAfterInit(state) + // 1 env-set + 1 run follow-up + 1 trailing. + require.Len(t, out, 3) + + assert.Equal(t, "azd env set MY_API_KEY ", out[0].Command) + assert.Equal(t, "referenced by agent.yaml but not set in azd env", out[0].Description, + "enriched description must explain WHY the env-set is needed") + assert.False(t, out[0].Trailing) + + assert.Equal(t, "azd ai agent run", out[1].Command) + assert.Equal(t, "start the agent locally once the values above are set", out[1].Description) + assert.False(t, out[1].Trailing, "run follow-up must be a primary suggestion") + + assert.Equal(t, "azd deploy", out[2].Command) + assert.True(t, out[2].Trailing) +} + +// TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary locks +// the spec-mandated two-line "everything ready" shape from issue #7975 +// lines 96-103: after `azd ai agent run`, append +// `azd ai agent invoke --local ` so the user knows what to +// try in another terminal. Also verifies protocol-aware payload selection +// (single-service state) and the priority ordering (run before invoke). +func TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary(t *testing.T) { + t.Parallel() + + t.Run("zero services → unqualified invoke with responses payload", func(t *testing.T) { + t.Parallel() + state := &State{HasProjectEndpoint: true} + out := ResolveAfterInit(state) + // run + invoke --local + trailing. + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent run", out[0].Command) + assert.Equal(t, "start the agent locally", out[0].Description) + assert.Equal(t, `azd ai agent invoke --local "Hello!"`, out[1].Command) + assert.Equal(t, "test it in another terminal", out[1].Description) + assert.Less(t, out[0].Priority, out[1].Priority, + "run must precede invoke --local; the renderer sorts by Priority") + assert.Equal(t, "azd deploy", out[2].Command) + assert.True(t, out[2].Trailing) + }) + + t.Run("single-agent responses protocol → invoke uses \"Hello!\"", func(t *testing.T) { + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + Services: []ServiceState{{Name: "echo", Protocol: ProtocolResponses}}, + } + out := ResolveAfterInit(state) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent run", out[0].Command) + assert.Equal(t, `azd ai agent invoke --local "Hello!"`, out[1].Command) + }) + + t.Run("single-agent invocations protocol → invoke uses JSON envelope", func(t *testing.T) { + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocations}}, + } + out := ResolveAfterInit(state) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent run", out[0].Command) + assert.Equal(t, `azd ai agent invoke --local '{"message": "Hello!"}'`, out[1].Command) + }) + + t.Run("multi-agent → invoke stays unqualified, defaults to responses payload", func(t *testing.T) { + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + Services: []ServiceState{ + {Name: "alpha", Protocol: ProtocolInvocations}, + {Name: "beta", Protocol: ProtocolResponses}, + }, + } + out := ResolveAfterInit(state) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent run", out[0].Command) + // Multi-agent: the unqualified `invoke --local` doesn't know + // which service the user will pick at runtime, so use the + // safest generic payload (responses-style "Hello!") instead + // of picking one service's protocol arbitrarily. + assert.Equal(t, `azd ai agent invoke --local "Hello!"`, out[1].Command) + }) + + t.Run("placeholders present → invoke-local secondary suppressed (with run)", func(t *testing.T) { + // Placeholders block local run entirely — the spec's default + // branch is gated on !hasPlaceholders, so neither `run` nor + // the invoke-local follow-up should appear when literal + // {{NAME}} values would land in the running container. + t.Parallel() + state := &State{ + HasProjectEndpoint: true, + UnresolvedPlaceholders: []string{"FOO"}, + } + out := ResolveAfterInit(state) + for _, s := range out { + assert.NotContains(t, s.Command, "azd ai agent invoke --local", + "invoke --local must not be emitted while placeholders are unresolved") + assert.NotEqual(t, "azd ai agent run", s.Command, + "azd ai agent run must not be emitted while placeholders are unresolved") + } + }) +} + +// TestResolveAfterInit_ToolboxReproRendersAllCategories locks the full +// regression for the toolbox-sample bug end-to-end: the state contains +// BOTH an unresolved manifest placeholder AND a missing manual env var, +// and the rendered "Next:" block must surface both fix-up categories +// plus the trailing `azd deploy` reminder. PrintNext would silently +// drop one category here because of its 2-line cap; PrintAllNext must +// not. +func TestResolveAfterInit_ToolboxReproRendersAllCategories(t *testing.T) { + t.Parallel() + + state := &State{ + HasProjectEndpoint: true, + UnresolvedPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + MissingManualVars: []string{"TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT"}, + } + + var buf strings.Builder + require.NoError(t, PrintAllNext(&buf, ResolveAfterInit(state))) + rendered := buf.String() + + assert.Contains(t, rendered, + "edit agent.yaml: replace {{TOOLBOX_ENDPOINT}} with the actual value", + "placeholder fix-up missing") + assert.Contains(t, rendered, + "azd env set TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT ", + "manual-var fix-up missing — this is the original toolbox-sample regression") + // `azd ai agent run` follow-up is intentionally suppressed when + // UnresolvedPlaceholders are also present: running locally with + // literal `{{NAME}}` values produces a broken agent. The user + // must fix the placeholder first; the trailing `azd deploy` + // still applies. + assert.NotContains(t, rendered, "start the agent locally once the values above are set", + "run follow-up should be suppressed while placeholders are unresolved") + assert.Contains(t, rendered, "azd deploy", "trailing deploy reminder missing") +} + +func TestResolveAfterInit_UnresolvedPlaceholders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state *State + wantPlaceholders []string // expected `{{NAME}}` names in order + wantMiddle string // expected non-trailing, non-placeholder primary (e.g., "azd provision", "azd env set FOO", or "" if none) + wantHasRun bool // expect `azd ai agent run` to appear? + wantHasDeploy bool // expect `azd deploy` trailing? + }{ + { + name: "placeholders alone → edit lines + deploy, no run", + state: &State{ + HasProjectEndpoint: true, + UnresolvedPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + }, + wantPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + wantHasRun: false, + wantHasDeploy: true, + }, + { + name: "placeholders + missing manual vars → both surfaced, no run", + state: &State{ + HasProjectEndpoint: true, + UnresolvedPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + MissingManualVars: []string{"TOOLBOX_MCP_ENDPOINT"}, + }, + wantPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + wantMiddle: "azd env set TOOLBOX_MCP_ENDPOINT", + wantHasRun: false, + wantHasDeploy: true, + }, + { + name: "placeholders + project endpoint missing → placeholders + provision", + state: &State{ + HasProjectEndpoint: false, + UnresolvedPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + }, + wantPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + wantMiddle: "azd provision", + wantHasRun: false, + wantHasDeploy: true, + }, + { + name: "multiple placeholders sorted ascending", + state: &State{ + HasProjectEndpoint: true, + UnresolvedPlaceholders: []string{"CHARLIE", "ALPHA", "BRAVO"}, + }, + wantPlaceholders: []string{"ALPHA", "BRAVO", "CHARLIE"}, + wantHasRun: false, + wantHasDeploy: true, + }, + { + name: "more than three placeholders capped at three", + state: &State{ + HasProjectEndpoint: true, + UnresolvedPlaceholders: []string{"P1", "P2", "P3", "P4", "P5"}, + }, + wantPlaceholders: []string{"P1", "P2", "P3"}, + wantHasRun: false, + wantHasDeploy: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + out := ResolveAfterInit(tt.state) + require.NotEmpty(t, out) + + // Walk the output: + // 1. leading run of placeholder fix-ups (one per wantPlaceholders[i]) + // 2. optional middle command (provision / env set) + // 3. optional `azd ai agent run` + // 4. trailing `azd deploy` + for i, name := range tt.wantPlaceholders { + require.Less(t, i, len(out)) + assert.Equal(t, + "edit agent.yaml: replace {{"+name+"}} with the actual value", + out[i].Command, + ) + } + + // The middle (if any) sits just past the placeholders. + if tt.wantMiddle != "" { + idx := len(tt.wantPlaceholders) + require.Less(t, idx, len(out)) + assert.True(t, + strings.HasPrefix(out[idx].Command, tt.wantMiddle), + "middle suggestion %q does not have prefix %q", + out[idx].Command, tt.wantMiddle, + ) + } + + hasRun := false + hasDeploy := false + for _, s := range out { + switch { + case s.Command == "azd ai agent run": + hasRun = true + case s.Command == "azd deploy" && s.Trailing: + hasDeploy = true + } + } + assert.Equal(t, tt.wantHasRun, hasRun, + "presence of `azd ai agent run` mismatched") + assert.Equal(t, tt.wantHasDeploy, hasDeploy, + "presence of trailing `azd deploy` mismatched") + }) + } +} + +func TestResolveAfterRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state *State + serviceName string + want []string // expected substrings, one per emitted command + }{ + { + name: "OpenAPI payload extracted → invoke with payload, no tip", + state: &State{ + HasOpenAPI: true, + OpenAPIPayload: `{"message":"hello"}`, + Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocations}}, + }, + serviceName: "echo", + want: []string{ + `azd ai agent invoke --local '{"message":"hello"}'`, + }, + }, + { + name: "invocations protocol, no spec → default JSON payload + tip", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocations}}, + }, + serviceName: "echo", + want: []string{ + `azd ai agent invoke --local '{"message": "Hello!"}'`, + `curl http://localhost:/invocations/docs/openapi.json`, + }, + }, + { + name: "responses protocol, no spec → Hello! string + tip", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ProtocolResponses}}, + }, + serviceName: "echo", + want: []string{ + `azd ai agent invoke --local "Hello!"`, + `curl http://localhost:/invocations/docs/openapi.json`, + }, + }, + { + name: "unknown protocol falls back to responses default", + state: &State{ + Services: []ServiceState{{Name: "echo", Protocol: ""}}, + }, + serviceName: "echo", + want: []string{ + `azd ai agent invoke --local "Hello!"`, + `curl http://localhost:/invocations/docs/openapi.json`, + }, + }, + { + name: "service name omitted, single-service project picks that one", + state: &State{ + Services: []ServiceState{{Name: "only", Protocol: ProtocolInvocations}}, + }, + serviceName: "", + want: []string{ + `azd ai agent invoke --local '{"message": "Hello!"}'`, + `curl http://localhost:/invocations/docs/openapi.json`, + }, + }, + { + name: "OpenAPI payload with apostrophe → POSIX-escaped wrap, no tip", + state: &State{ + HasOpenAPI: true, + OpenAPIPayload: `{"q":"don't"}`, + Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocations}}, + }, + serviceName: "echo", + want: []string{ + `azd ai agent invoke --local '{"q":"don'\''t"}'`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + out := ResolveAfterRun(tt.state, tt.serviceName) + require.Len(t, out, len(tt.want)) + for i, snippet := range tt.want { + assert.Contains(t, out[i].Command, snippet) + } + }) + } +} + +func TestResolveAfterRun_NilState(t *testing.T) { + t.Parallel() + assert.Nil(t, ResolveAfterRun(nil, "")) +} + +func TestResolveAfterInvoke_Success(t *testing.T) { + t.Parallel() + + t.Run("local success → deploy + monitor", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeLocal, "", nil) + // Issue #7975 lines 168-181: local-invoke success surfaces + // both `azd deploy` (ship to Azure) and the live-log monitor + // follow-up (verify the deployed copy is healthy). + require.Len(t, out, 2) + + assert.Equal(t, "azd deploy", out[0].Command) + assert.Equal(t, "deploy the agent to Azure", out[0].Description) + assert.False(t, out[0].Trailing, + "primary suggestion must not be Trailing") + + assert.Equal(t, "azd ai agent monitor --follow", out[1].Command) + assert.Equal(t, "view logs after deploying", out[1].Description) + assert.False(t, out[1].Trailing, + "secondary suggestion must not be Trailing") + + // Priority ordering matters: PrintNext / PrintAllNext stable-sort + // by Priority ascending, so the slice position alone does NOT + // guarantee the rendered order. Locking priorities here prevents + // a future edit from accidentally inverting the values and + // making `monitor --follow` render before `azd deploy`. Mirrors + // the failure-path pattern on the remote-failure test below. + assert.Less(t, out[0].Priority, out[1].Priority, + "deploy must sort before monitor --follow") + }) + + t.Run("remote success with agent name → show + monitor", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeRemote, "echo", nil) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, "azd ai agent monitor --follow", out[1].Command) + }) + + t.Run("remote success without agent name → show only", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeRemote, "", nil) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show", out[0].Command) + }) +} + +func TestResolveAfterInvoke_Failure(t *testing.T) { + t.Parallel() + + t.Run("local failure → see local server output", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeLocal, "", &InvokeFailure{}) + require.Len(t, out, 1) + assert.Contains(t, out[0].Command, "local server output") + }) + + t.Run("remote failure, no session code → generic monitor", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeRemote, "echo", &InvokeFailure{}) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent monitor --tail 100", out[0].Command) + }) + + t.Run("remote failure with classified code → branched remediation", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeRemote, "echo", &InvokeFailure{ + SessionCode: SessionQuotaExceeded, + }) + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent session list", out[0].Command) + }) + + t.Run("remote failure with secondary action → both lines, ordered priority", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeRemote, "echo", &InvokeFailure{ + SessionCode: SessionReadinessTimeout, + }) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent invoke", out[0].Command) + assert.Less(t, out[0].Priority, out[1].Priority) + }) + + t.Run("unrecognized session code → fallback with code in description", func(t *testing.T) { + t.Parallel() + out := ResolveAfterInvoke(&State{}, InvokeRemote, "echo", &InvokeFailure{ + SessionCode: SessionErrorCode("MysteryCode"), + }) + require.Len(t, out, 1) + assert.Contains(t, out[0].Description, "MysteryCode") + }) +} + +func TestResolveAfterShow(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status AgentVersionStatus + serviceName string + wantCmdHas string + }{ + {"Creating → monitor system", AgentVersionCreating, "echo", "azd ai agent monitor --type system --follow"}, + {"Failed → monitor --follow", AgentVersionFailed, "echo", "azd ai agent monitor --follow"}, + {"Deleting → redeploy", AgentVersionDeleting, "echo", "azd deploy"}, + {"Deleted → redeploy", AgentVersionDeleted, "echo", "azd deploy"}, + {"empty status → monitor --follow", "", "echo", "azd ai agent monitor --follow"}, + {"unknown status → re-check show", "Transitioning", "echo", "azd ai agent show echo"}, + {"unknown status without agent name → bare show", "Transitioning", "", "azd ai agent show"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Same-name case: service and agent names align (common when deploy + // doesn't append a suffix). Divergent-name behavior is exercised by + // TestResolveAfterShow_DivergentNames below — the resolver always + // emits the service name in the unknown-status re-check. + out := ResolveAfterShow(&State{AgentStatus: string(tt.status)}, tt.serviceName) + require.NotEmpty(t, out) + assert.Contains(t, out[0].Command, tt.wantCmdHas) + }) + } +} + +func TestResolveAfterShow_ActiveAndIdleReturnNil(t *testing.T) { + t.Parallel() + + for _, status := range []AgentVersionStatus{AgentVersionActive, AgentVersionIdle} { + t.Run(string(status), func(t *testing.T) { + t.Parallel() + state := &State{ + AgentStatus: string(status), + Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocations}}, + } + assert.Nil(t, ResolveAfterShow(state, "echo")) + }) + } +} + +// TestResolveAfterShow_DivergentNames locks the divergent-name contract: +// when the azure.yaml service name and the deployed Foundry agent name +// differ, the unknown-status re-check suggestion uses the SERVICE name +// as the positional because show.go's lookup matches by service name. +func TestResolveAfterShow_DivergentNames(t *testing.T) { + t.Parallel() + + t.Run("unknown status: re-check uses service name", func(t *testing.T) { + t.Parallel() + out := ResolveAfterShow(&State{AgentStatus: "Transitioning"}, "svc-echo") + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show svc-echo", out[0].Command) + }) +} + +func TestResolveAfterShow_NilState(t *testing.T) { + t.Parallel() + assert.Nil(t, ResolveAfterShow(nil, "echo")) +} + +func TestResolveAfterDeploy(t *testing.T) { + t.Parallel() + + t.Run("single agent, cached payload available → 2 qualified lines, no README hint", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo"}}} + cached := func(_ string) string { return `{"q":"x"}` } + out := ResolveAfterDeploy(state, cached, nil) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, "verify it's running", out[0].Description) + assert.Equal(t, `azd ai agent invoke echo '{"q":"x"}'`, out[1].Command) + assert.Equal(t, "test the deployment", out[1].Description) + }) + + t.Run("single agent, no cached payload, README on disk → README then placeholder invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolResponses}}} + readme := func(p string) bool { return p == "./src/echo" } + out := ResolveAfterDeploy(state, nil, readme) + require.Len(t, out, 3) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, "verify it's running", out[0].Description) + assert.Equal(t, "see src/echo/README.md", out[1].Command) + assert.Equal(t, "find the sample-specific payload", out[1].Description) + assert.Equal(t, `azd ai agent invoke echo ''`, out[2].Command) + assert.Equal(t, "test with the sample-specific payload", out[2].Description) + }) + + t.Run("single root agent, README on disk → root README hint", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rel string + }{ + {name: "empty", rel: ""}, + {name: "dot", rel: "."}, + {name: "dot slash", rel: "./"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + state := &State{Services: []ServiceState{{Name: "echo", RelativePath: tt.rel, Protocol: ProtocolResponses}}} + readme := func(p string) bool { return p == tt.rel } + + out := ResolveAfterDeploy(state, nil, readme) + + require.Len(t, out, 3) + assert.Equal(t, "see README.md", out[1].Command) + assert.Equal(t, "find the sample-specific payload", out[1].Description) + assert.Equal(t, `azd ai agent invoke echo ''`, out[2].Command) + }) + } + }) + + t.Run("multi-agent → all shows first, then all invokes, with per-agent descriptions", func(t *testing.T) { + // Spec source: issue #7975 lines 238-241 — multi-agent layout + // groups shows before invokes (not interleaved) and bakes the + // agent name into the description so users can scan vertically. + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "alpha", Protocol: ProtocolInvocations}, + {Name: "beta", Protocol: ProtocolResponses}, + }} + out := ResolveAfterDeploy(state, nil, nil) + require.Len(t, out, 4) + assert.Equal(t, "azd ai agent show alpha", out[0].Command) + assert.Equal(t, "verify alpha is running", out[0].Description) + assert.Equal(t, "azd ai agent show beta", out[1].Command) + assert.Equal(t, "verify beta is running", out[1].Description) + assert.Equal(t, `azd ai agent invoke alpha '{"message": "Hello!"}'`, out[2].Command) + assert.Equal(t, "test alpha with a generic payload", out[2].Description) + assert.Equal(t, `azd ai agent invoke beta "Hello!"`, out[3].Command) + assert.Equal(t, "test beta with a generic payload", out[3].Description) + }) + + t.Run("multi-agent README hint placement → before the corresponding placeholder invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "alpha", RelativePath: "./src/alpha", Protocol: ProtocolResponses}, + {Name: "beta", Protocol: ProtocolResponses}, + }} + readme := func(p string) bool { return p == "./src/alpha" } + out := ResolveAfterDeploy(state, nil, readme) + // 2 shows + 2 invokes + 1 README hint for alpha = 5 entries. + require.Len(t, out, 5) + assert.Equal(t, "azd ai agent show alpha", out[0].Command) + assert.Equal(t, "azd ai agent show beta", out[1].Command) + assert.Equal(t, "see src/alpha/README.md", out[2].Command) + assert.Equal(t, "find the sample-specific payload", out[2].Description) + assert.Equal(t, `azd ai agent invoke alpha ''`, out[3].Command) + assert.Equal(t, "test alpha with the sample-specific payload", out[3].Description) + assert.Equal(t, `azd ai agent invoke beta "Hello!"`, out[4].Command) + assert.Equal(t, "test beta with a generic payload", out[4].Description) + }) + + t.Run("README hint skipped when cached payload is present", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo"}}} + cached := func(_ string) string { return `{"q":"x"}` } + readme := func(_ string) bool { return true } + out := ResolveAfterDeploy(state, cached, readme) + assert.Len(t, out, 2) + }) + + t.Run("no services → nil", func(t *testing.T) { + t.Parallel() + assert.Nil(t, ResolveAfterDeploy(&State{}, nil, nil)) + }) + + t.Run("nil state → nil", func(t *testing.T) { + t.Parallel() + assert.Nil(t, ResolveAfterDeploy(nil, nil, nil)) + }) + + t.Run("cached payload containing apostrophe → POSIX-escaped on qualified invoke", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo"}}} + cached := func(_ string) string { return `{"q":"don't"}` } + out := ResolveAfterDeploy(state, cached, nil) + require.Len(t, out, 2) + assert.Equal(t, `azd ai agent invoke echo '{"q":"don'\''t"}'`, out[1].Command) + }) + + t.Run("ForceQualified=true on len==1 → no-op, output identical to default", func(t *testing.T) { + // Backward-compat assertion: B9 makes all output qualified by + // default; ForceQualified is preserved as a no-op for callers + // (e.g., doctor) that still pass it. Result must match the + // "no opts" call exactly. + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolInvocations}, + }} + out := ResolveAfterDeploy(state, nil, nil, AfterDeployOpts{ForceQualified: true}) + baseline := ResolveAfterDeploy(state, nil, nil) + require.Equal(t, baseline, out) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, `azd ai agent invoke echo '{"message": "Hello!"}'`, out[1].Command) + }) + + t.Run("ForceQualified=false on len==1 → no-op, also qualified", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolInvocations}, + }} + out := ResolveAfterDeploy(state, nil, nil, AfterDeployOpts{ForceQualified: false}) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, `azd ai agent invoke echo '{"message": "Hello!"}'`, out[1].Command) + }) + + t.Run("ForceQualified=true with cached payload → qualified invoke uses payload", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{{Name: "echo", RelativePath: "./src/echo"}}} + cached := func(_ string) string { return `{"q":"x"}` } + out := ResolveAfterDeploy(state, cached, nil, AfterDeployOpts{ForceQualified: true}) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + assert.Equal(t, `azd ai agent invoke echo '{"q":"x"}'`, out[1].Command) + }) + + t.Run("ForceQualified=true on multi-agent → identical to default multi-agent layout", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "alpha", Protocol: ProtocolInvocations}, + {Name: "beta", Protocol: ProtocolResponses}, + }} + out := ResolveAfterDeploy(state, nil, nil, AfterDeployOpts{ForceQualified: true}) + require.Len(t, out, 4) + assert.Equal(t, "azd ai agent show alpha", out[0].Command) + assert.Equal(t, "azd ai agent show beta", out[1].Command) + assert.Equal(t, `azd ai agent invoke alpha '{"message": "Hello!"}'`, out[2].Command) + assert.Equal(t, `azd ai agent invoke beta "Hello!"`, out[3].Command) + }) + + t.Run("extra opts elements beyond [0] are ignored", func(t *testing.T) { + t.Parallel() + state := &State{Services: []ServiceState{ + {Name: "echo", RelativePath: "./src/echo", Protocol: ProtocolInvocations}, + }} + out := ResolveAfterDeploy( + state, nil, nil, + AfterDeployOpts{ForceQualified: true}, + AfterDeployOpts{ForceQualified: false}, // should be ignored + ) + require.Len(t, out, 2) + assert.Equal(t, "azd ai agent show echo", out[0].Command) + }) +} + +func TestFindService(t *testing.T) { + t.Parallel() + + state := &State{Services: []ServiceState{ + {Name: "alpha"}, + {Name: "beta"}, + }} + + assert.Equal(t, "alpha", findService(state, "alpha").Name) + assert.Equal(t, "beta", findService(state, "beta").Name) + assert.Nil(t, findService(state, "missing")) + + // Empty name + single service → that one. + single := &State{Services: []ServiceState{{Name: "only"}}} + assert.Equal(t, "only", findService(single, "").Name) + + // Empty name + multiple → nil. + assert.Nil(t, findService(state, "")) + + // Nil state. + assert.Nil(t, findService(nil, "alpha")) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go new file mode 100644 index 00000000000..8d81faad76d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -0,0 +1,622 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "context" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "go.yaml.in/yaml/v3" +) + +const ( + // agentHost matches the value set in azure.yaml for an azure.ai.agent + // service. Duplicated here (rather than imported from the parent cmd + // package) so nextstep stays free of upward dependencies; Phase 2 will + // wire cmd → nextstep, so the reverse import would close a cycle. + agentHost = "azure.ai.agent" + + // agentVersionVarFormat is the env-var name that signals a deployed + // agent service. Filled with the upper-cased service key. + agentVersionVarFormat = "AGENT_%s_VERSION" + + // projectEndpointVar is the env-var that carries the Foundry project + // endpoint URL produced by `azd ai agent init`. + projectEndpointVar = "AZURE_AI_PROJECT_ENDPOINT" + + // useExistingAIProjectVar was removed in 4.13. The + // USE_EXISTING_AI_PROJECT env var is still written by `azd ai + // agent init` for Bicep's "skip project creation" branch, but + // the resolver no longer consumes it directly — the + // equivalent "project not yet provisioned" signal is now + // expressed via the "project" tag in AI_AGENT_PENDING_PROVISION + // (see pendingProvisionVar below and pending_provision.go in + // the cmd package). Single source of truth keeps the producer + // (init.go) and consumer (this resolver) in lock-step without a + // second env-var contract to maintain. + + // pendingProvisionVar names the extension-owned env var that + // lists resource-class tags init configured but provision has + // not yet materialized. See State.PendingProvisionReasons for + // the full semantics and pending_provision.go in the cmd package + // for the read/write helpers and the reason-tag taxonomy. The + // constant is duplicated here (rather than imported from cmd) + // because nextstep is a leaf package with no dependency on cmd + // — both packages share the same string literal contract. + pendingProvisionVar = "AI_AGENT_PENDING_PROVISION" +) + +// envVarRefPattern captures ${VAR} references inside YAML string values. +// Group 1 is the variable name. Group 2 captures the optional default +// tail `:-fallback`; when group 2 is non-empty the agent.yaml author +// explicitly opted into a fallback and the variable is therefore not +// required at deploy time (the runtime expander `drone/envsubst` honors +// `:-` semantics). `extractAgentYamlEnvRefs` skips refs with a non-empty +// group 2 so they never surface in the missing-vars hints; the variable +// is reported as missing only when authored as the bare `${VAR}` form. +// Variable names follow the standard shell convention: leading letter or +// underscore, then alphanumeric or underscore. +var envVarRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}`) + +// placeholderPattern aliases agent_yaml.PlaceholderPattern. nextstep +// surfaces the same placeholders that agent_yaml's +// injectParameterValues warns about, so the two MUST stay in lockstep. +// Keeping a single shared regex (defined in agent_yaml, where the +// substitution logic lives) makes that constraint explicit and avoids +// drift if the placeholder syntax is ever broadened again. See +// agent_yaml/placeholders.go for the full rationale on the regex +// shape (hyphens, dots, whitespace in capture group). +var placeholderPattern = agent_yaml.PlaceholderPattern + +// Source is the read-only view of azd that AssembleState needs. +// +// The production implementation wraps an *azdext.AzdClient via NewSource; +// tests inject a fake. The split keeps the package free of gRPC plumbing. +type Source interface { + // Project returns the parsed azure.yaml of the current project, or an + // error if no project is present. + Project(ctx context.Context) (*azdext.ProjectConfig, error) + // CurrentEnvName returns the name of the active azd environment. + CurrentEnvName(ctx context.Context) (string, error) + // EnvValue returns the value of key in the named environment. An empty + // string with a nil error means the key is unset; transport errors are + // surfaced verbatim. + EnvValue(ctx context.Context, envName, key string) (string, error) +} + +// NewSource adapts an *azdext.AzdClient to the Source interface. The +// returned Source borrows the client; the caller retains ownership and +// is responsible for closing it. +func NewSource(client *azdext.AzdClient) Source { + return &clientSource{client: client} +} + +type clientSource struct { + client *azdext.AzdClient +} + +func (s *clientSource) Project(ctx context.Context) (*azdext.ProjectConfig, error) { + resp, err := s.client.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, err + } + if resp == nil || resp.Project == nil { + return nil, errors.New("azd returned an empty project response") + } + return resp.Project, nil +} + +func (s *clientSource) CurrentEnvName(ctx context.Context) (string, error) { + resp, err := s.client.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return "", err + } + if resp == nil || resp.Environment == nil { + return "", errors.New("azd returned an empty environment response") + } + return resp.Environment.Name, nil +} + +func (s *clientSource) EnvValue(ctx context.Context, envName, key string) (string, error) { + resp, err := s.client.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: key, + }) + if err != nil { + return "", err + } + if resp == nil { + return "", nil + } + return resp.Value, nil +} + +// Option configures AssembleState. +type Option func(*config) + +type config struct { + // openAPIAgent and openAPISuffix together enable a cache-only OpenAPI + // payload lookup. The zero value (empty strings) disables the probe. + openAPIAgent string + openAPISuffix string + + // openAPILiveFetch, when non-nil, is consulted before the on-disk + // cache: a non-empty body wins and is used for example extraction. + // On error or empty body the assembler silently falls back to the + // cache lookup configured via WithOpenAPIProbe. Used by + // `azd ai agent run` to surface a fresh sample without making the + // on-disk cache the source of truth. + openAPILiveFetch func(context.Context) ([]byte, error) +} + +// WithOpenAPIProbe enables a cache-only OpenAPI lookup for (agentName, suffix). +// Empty inputs disable the probe; misses or malformed specs leave HasOpenAPI +// false. Combine with WithLiveOpenAPIProbe to prefer a fresh in-process fetch. +func WithOpenAPIProbe(agentName, suffix string) Option { + return func(c *config) { + c.openAPIAgent = agentName + c.openAPISuffix = suffix + } +} + +// WithLiveOpenAPIProbe enables an HTTP fetch of the agent's OpenAPI +// spec. When the supplied closure returns a non-empty byte slice with a +// nil error, those bytes are used for example extraction in preference +// to the on-disk cache; any error or empty body falls back to the +// cache lookup configured via WithOpenAPIProbe. +// +// The caller owns the probe's timeout — pass a closure that wraps the +// HTTP call in its own short-lived context (the design budget is 3 s +// for `azd ai agent run`). The probe is intended for transient "just +// started" scenarios where the live spec is authoritative; cache-only +// paths (show / deploy) should not register a live probe. +func WithLiveOpenAPIProbe(fetch func(context.Context) ([]byte, error)) Option { + return func(c *config) { c.openAPILiveFetch = fetch } +} + +// AssembleState builds a State snapshot for the current azd environment. +// +// All probes are best-effort: transport or parse errors are collected +// and returned alongside a partially-populated state, so the resolver +// can still degrade gracefully (e.g., suggest `azd init` when project +// load fails). Callers should render guidance from the returned State +// even when len(errs) > 0. +func AssembleState( + ctx context.Context, + client *azdext.AzdClient, + opts ...Option, +) (*State, []error) { + return assembleState(ctx, NewSource(client), opts...) +} + +// AssembleStateFromSource is the Source-injecting variant of AssembleState. +// Production reaches this via show.go's `resolveNextStepFromSource`, which +// constructs a Source explicitly so it can later be swapped for a fake in +// tests. Use AssembleState directly when constructing from a real +// *azdext.AzdClient; use this when you already have a Source (production +// or test fake). +func AssembleStateFromSource( + ctx context.Context, + src Source, + opts ...Option, +) (*State, []error) { + return assembleState(ctx, src, opts...) +} + +func assembleState(ctx context.Context, src Source, opts ...Option) (*State, []error) { + cfg := &config{} + for _, opt := range opts { + opt(cfg) + } + + state := &State{} + var errs []error + + envName, err := src.CurrentEnvName(ctx) + if err != nil { + errs = append(errs, fmt.Errorf("read current environment: %w", err)) + } + + if envName != "" { + endpoint, err := src.EnvValue(ctx, envName, projectEndpointVar) + if err != nil { + errs = append(errs, fmt.Errorf("read %s: %w", projectEndpointVar, err)) + } + state.HasProjectEndpoint = endpoint != "" + + // PendingProvisionReasons is the generalized "init configured + // something provision still has to materialize" signal that + // the model-deployment / ACR / App-Insights blank-input + // branches write into. Read here so the resolver and doctor + // share one snapshot. Unknown tags are kept verbatim — the + // resolver only checks for non-emptiness, and downstream + // readers may interpret tags they recognize. Transport + // errors are surfaced into errs but do not abort assembly; + // the field is best-effort and the resolver tolerates an + // empty list (it falls back to legacy heuristics in that + // case). + pending, err := src.EnvValue(ctx, envName, pendingProvisionVar) + if err != nil { + errs = append(errs, fmt.Errorf("read %s: %w", pendingProvisionVar, err)) + } + state.PendingProvisionReasons = parsePendingProvisionReasons(pending) + } + + project, err := src.Project(ctx) + if err != nil { + errs = append(errs, fmt.Errorf("load project: %w", err)) + } + + state.Services = collectServices(ctx, src, envName, project, &errs) + + if project != nil && envName != "" { + state.MissingInfraVars, state.MissingManualVars, state.UnresolvedPlaceholders = detectMissingVars( + ctx, src, envName, project.Path, state.Services, &errs, + ) + populateOpenAPIPayload(ctx, cfg, project.Path, envName, state) + } + + if project != nil && len(state.Services) > 0 { + populateManifestResources(project.Path, state) + } + + return state, errs +} + +// populateOpenAPIPayload locates a sample invoke payload for the +// resolver. When a live probe is registered (via +// WithLiveOpenAPIProbe) the closure is consulted first and its +// non-empty body wins; otherwise — or on error / empty body — the +// on-disk cache produced by fetchOpenAPISpec is consulted. All +// failure modes (probe disabled, fetch error, cache miss, malformed +// spec, no extractable payload) leave state.HasOpenAPI false so the +// resolver can fall back to the protocol-generic literal. +// +// Live-fetch errors are silently absorbed: the doctor / `run` paths +// must not surface partial-network diagnostics here — the user's +// terminal is the wrong surface for them and a transient probe +// failure should never block the cached fallback. +func populateOpenAPIPayload( + ctx context.Context, + cfg *config, + projectPath, envName string, + state *State, +) { + var specBytes []byte + if cfg.openAPILiveFetch != nil { + if b, err := cfg.openAPILiveFetch(ctx); err == nil && len(b) > 0 { + specBytes = b + } + } + if len(specBytes) == 0 { + if cfg.openAPIAgent == "" || cfg.openAPISuffix == "" { + return + } + configDir := filepath.Join(projectPath, ".azure", envName) + b, err := ReadCachedOpenAPISpec(configDir, cfg.openAPIAgent, cfg.openAPISuffix) + if err != nil || len(b) == 0 { + return + } + specBytes = b + } + payload := ExtractInvokeExample(specBytes) + if payload == "" { + return + } + state.HasOpenAPI = true + state.OpenAPIPayload = payload +} + +func collectServices( + ctx context.Context, + src Source, + envName string, + project *azdext.ProjectConfig, + errs *[]error, +) []ServiceState { + if project == nil || len(project.Services) == 0 { + return nil + } + + services := make([]ServiceState, 0, len(project.Services)) + for _, svc := range project.Services { + if svc == nil || svc.Host != agentHost { + continue + } + services = append(services, ServiceState{ + Name: svc.Name, + Host: svc.Host, + RelativePath: svc.RelativePath, + Protocol: loadServiceProtocol(project.Path, svc.RelativePath), + IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs), + }) + } + + slices.SortFunc(services, func(a, b ServiceState) int { + return strings.Compare(a.Name, b.Name) + }) + return services +} + +// loadServiceProtocol returns the protocol the service's agent.yaml declares +// for next-step hint purposes. The lookup is best-effort: missing or +// malformed manifests, empty protocols sections, or any I/O error all return +// an empty string, and the resolver falls back to ProtocolResponses. When the +// manifest declares multiple protocols, ProtocolResponses wins over +// ProtocolInvocations so the suggested payload works on the broadest set of +// agents. +func loadServiceProtocol(projectPath, relativePath string) string { + if projectPath == "" { + return "" + } + manifestPath, err := paths.JoinAllowRoot(projectPath, relativePath, "agent.yaml") + if err != nil { + return "" + } + data, err := os.ReadFile(manifestPath) //nolint:gosec // path is validated under the project root + if err != nil { + return "" + } + var hosted agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &hosted); err != nil { + return "" + } + + sawInvocations := false + for _, p := range hosted.Protocols { + switch strings.TrimSpace(p.Protocol) { + case ProtocolResponses: + return ProtocolResponses + case ProtocolInvocations: + sawInvocations = true + } + } + if sawInvocations { + return ProtocolInvocations + } + return "" +} + +// detectMissingVars walks each service's agent.yaml environment_variables +// section and partitions the trouble-spots into three lists: +// +// 1. infra: unset ${VAR} refs that name a top-level output of +// /infra/main.bicep (provision outputs) +// 2. manual: unset ${VAR} refs that do NOT name a Bicep output +// (user inputs the user must `azd env set`) +// 3. placeholders: surviving {{NAME}} Mustache placeholders (init failed +// to substitute these from agent.manifest.yaml's parameters block) +// +// Only bare-form ${VAR} refs participate in (1) and (2): when the +// agent.yaml author supplies an explicit fallback via `${VAR:-default}`, +// the deploy-time resolver substitutes the fallback and the variable is +// not required. `extractAgentYamlEnvRefs` filters defaulted refs out. +// +// Classification rule for ${VAR}: a variable is an infra var iff its +// name is declared as a top-level `output` in `/infra/ +// main.bicep`. azd's Bicep provider writes those output names verbatim +// into `.azure//.env` after `azd provision` succeeds (see +// cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go around the +// `outputs[key] = ...` write and pkg/infra/provisioning/manager.go's +// `UpdateEnvironment` → `env.DotenvSet(key, ...)`), so set membership +// is a precise signal of "this variable is provided by `azd provision`." +// Everything else is treated as a user-supplied manual variable that +// the user must set via `azd env set`. This mirrors the spec wording in +// issue #7975 ("Walk azure.yaml service configs; collect ${...} +// references that map to known Bicep outputs"). +// +// When `infra/main.bicep` is missing or declares no outputs, the +// Bicep-output set is empty and every unresolved bare ref lands in the +// manual bucket. This is the conservative answer: the resolver will +// emit `azd env set ` hints, which a user can always +// follow. If the project is actually backed by a Bicep template whose +// outputs are not yet declared, the fix is to declare the missing +// output — not to guess based on the variable name. +// +// {{NAME}} placeholders are reported separately because the user cannot +// fix them with `azd env set` — the placeholder is literally inside +// agent.yaml and would land in the container as `{{NAME}}` at deploy +// time. The resolver surfaces an "edit agent.yaml" suggestion for each. +// +// All three result lists are deduplicated and sorted ascending. Read +// errors on individual agent.yaml files are silent: the resolver should +// fall back to the default branch rather than emit guidance that +// mentions variables we cannot prove are needed. Transport errors from +// src.EnvValue are appended to errs so AssembleState's caller can +// surface them in --debug logs without aborting the snapshot. +func detectMissingVars( + ctx context.Context, + src Source, + envName, projectPath string, + services []ServiceState, + errs *[]error, +) (infra, manual, placeholders []string) { + if envName == "" || projectPath == "" || len(services) == 0 { + return nil, nil, nil + } + + bicepOutputs := bicepOutputSet(projectPath) + seenInfra := make(map[string]struct{}) + seenManual := make(map[string]struct{}) + seenPlaceholder := make(map[string]struct{}) + + for _, svc := range services { + refs, phs := extractAgentYamlEnvRefs(projectPath, svc.RelativePath) + for _, name := range refs { + if _, ok := seenInfra[name]; ok { + continue + } + if _, ok := seenManual[name]; ok { + continue + } + value, err := src.EnvValue(ctx, envName, name) + if err != nil { + *errs = append(*errs, fmt.Errorf("read %s: %w", name, err)) + continue + } + if value != "" { + continue + } + if _, isBicepOutput := bicepOutputs[name]; isBicepOutput { + seenInfra[name] = struct{}{} + } else { + seenManual[name] = struct{}{} + } + } + for _, name := range phs { + seenPlaceholder[name] = struct{}{} + } + } + + infra = slices.Sorted(maps.Keys(seenInfra)) + manual = slices.Sorted(maps.Keys(seenManual)) + placeholders = slices.Sorted(maps.Keys(seenPlaceholder)) + return infra, manual, placeholders +} + +// bicepOutputSet returns the Bicep-output names declared by +// /infra/main.bicep as a lookup set. Best-effort: a +// missing file, malformed content, or zero outputs return an empty +// (but non-nil) map so callers can use the idiomatic `_, ok := set[k]` +// form without nil-guarding. +func bicepOutputSet(projectPath string) map[string]struct{} { + names := discoverBicepOutputs(projectPath) + set := make(map[string]struct{}, len(names)) + for _, n := range names { + set[n] = struct{}{} + } + return set +} + +// extractAgentYamlEnvRefs returns two lists from the service's +// agent.yaml environment_variables block: +// +// 1. refs: unique bare-form ${VAR} names. Refs that supply a fallback +// via `${VAR:-default}` are skipped — the deploy-time expander +// honors the default, so the variable is not required and never +// warrants a missing-var hint. +// 2. placeholders: unique {{NAME}} Mustache-style placeholders that +// init's manifest processing failed to substitute. These would land +// in the container literally as `{{NAME}}` at deploy time. +// +// Order matches first appearance in the file. Missing or malformed +// manifests return nil for both — consistent with loadServiceProtocol's +// best-effort contract. +func extractAgentYamlEnvRefs(projectPath, relativePath string) (refs, placeholders []string) { + if projectPath == "" { + return nil, nil + } + manifestPath, err := paths.JoinAllowRoot(projectPath, relativePath, "agent.yaml") + if err != nil { + return nil, nil + } + data, err := os.ReadFile(manifestPath) //nolint:gosec // path is validated under the project root + if err != nil { + return nil, nil + } + var hosted agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &hosted); err != nil { + return nil, nil + } + if hosted.EnvironmentVariables == nil { + return nil, nil + } + + seenRef := make(map[string]struct{}) + seenPh := make(map[string]struct{}) + for _, ev := range *hosted.EnvironmentVariables { + for _, m := range envVarRefPattern.FindAllStringSubmatch(ev.Value, -1) { + if m[2] != "" { + // Variable carries an explicit `:-fallback` default; the + // deploy-time resolver honors it, so the user does not need + // to set the var. Skipping here keeps the next-step hint + // honest: only bare-form refs become missing-var prompts. + continue + } + name := m[1] + if _, ok := seenRef[name]; ok { + continue + } + seenRef[name] = struct{}{} + refs = append(refs, name) + } + for _, m := range placeholderPattern.FindAllStringSubmatch(ev.Value, -1) { + name := m[1] + if _, ok := seenPh[name]; ok { + continue + } + seenPh[name] = struct{}{} + placeholders = append(placeholders, name) + } + } + return refs, placeholders +} + +func isDeployed( + ctx context.Context, + src Source, + envName, serviceName string, + errs *[]error, +) bool { + if envName == "" || serviceName == "" { + return false + } + key := fmt.Sprintf(agentVersionVarFormat, serviceKey(serviceName)) + value, err := src.EnvValue(ctx, envName, key) + if err != nil { + *errs = append(*errs, fmt.Errorf("read %s: %w", key, err)) + return false + } + return value != "" +} + +// serviceKey converts a service name into the env-var key fragment used by +// the deploy-time env-var writer in service_target_agent.go. It mirrors +// AgentServiceTargetProvider.getServiceKey verbatim. +func serviceKey(name string) string { + k := strings.ReplaceAll(name, " ", "_") + k = strings.ReplaceAll(k, "-", "_") + return strings.ToUpper(k) +} + +// parsePendingProvisionReasons splits the AI_AGENT_PENDING_PROVISION +// env-var value into a sorted, deduplicated, whitespace-trimmed list of +// reason tags. Empty input or input containing only separators returns +// nil. Malformed input is best-effort normalized — the env var is a +// hint signal and parse trouble should not abort state assembly. This +// helper mirrors cmd.parsePendingProvisionReasons; the duplication is +// intentional to keep nextstep a leaf package with no dependency on cmd. +func parsePendingProvisionReasons(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + seen := make(map[string]struct{}) + for raw := range strings.SplitSeq(value, ",") { + tag := strings.TrimSpace(raw) + if tag == "" { + continue + } + seen[tag] = struct{}{} + } + if len(seen) == 0 { + return nil + } + out := make([]string, 0, len(seen)) + for tag := range seen { + out = append(out, tag) + } + slices.Sort(out) + return out +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go new file mode 100644 index 00000000000..a3c2fec286f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -0,0 +1,1411 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeSource is a hand-rolled Source for table-driven tests. +type fakeSource struct { + envName string + envNameErr error + project *azdext.ProjectConfig + projectErr error + values map[string]string + valueErr error +} + +func (f *fakeSource) CurrentEnvName(_ context.Context) (string, error) { + return f.envName, f.envNameErr +} + +func (f *fakeSource) Project(_ context.Context) (*azdext.ProjectConfig, error) { + return f.project, f.projectErr +} + +func (f *fakeSource) EnvValue(_ context.Context, envName, key string) (string, error) { + if f.valueErr != nil { + return "", f.valueErr + } + return f.values[envName+"/"+key], nil +} + +func TestAssembleState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src *fakeSource + assert func(t *testing.T, state *State, errs []error) + errCount int + }{ + { + name: "no project, no env: state is empty and errors are surfaced", + src: &fakeSource{ + envNameErr: errors.New("no env"), + projectErr: errors.New("no project"), + }, + assert: func(t *testing.T, state *State, _ []error) { + assert.False(t, state.HasProjectEndpoint) + assert.Empty(t, state.Services) + }, + errCount: 2, + }, + { + name: "endpoint set, no services: HasProjectEndpoint true", + src: &fakeSource{ + envName: "dev", + values: map[string]string{"dev/AZURE_AI_PROJECT_ENDPOINT": "https://x.services.ai.azure.com"}, + project: &azdext.ProjectConfig{Name: "demo"}, + }, + assert: func(t *testing.T, state *State, _ []error) { + assert.True(t, state.HasProjectEndpoint) + assert.Empty(t, state.Services) + }, + }, + { + name: "endpoint unset, one undeployed service", + src: &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "./src/echo"}, + }, + }, + values: map[string]string{}, + }, + assert: func(t *testing.T, state *State, _ []error) { + assert.False(t, state.HasProjectEndpoint) + require.Len(t, state.Services, 1) + assert.Equal(t, "echo", state.Services[0].Name) + assert.Equal(t, agentHost, state.Services[0].Host) + assert.Equal(t, "./src/echo", state.Services[0].RelativePath) + assert.False(t, state.Services[0].IsDeployed) + }, + }, + { + name: "multiple services: deployed flag follows AGENT__VERSION, alphabetical order", + src: &fakeSource{ + envName: "prod", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "chat-bot": {Name: "chat-bot", Host: agentHost}, + "echo": {Name: "echo", Host: agentHost}, + "my service": {Name: "my service", Host: agentHost}, + }, + }, + values: map[string]string{ + "prod/AGENT_CHAT_BOT_VERSION": "1", + "prod/AGENT_MY_SERVICE_VERSION": "7", + // echo has no VERSION → not deployed + }, + }, + assert: func(t *testing.T, state *State, _ []error) { + require.Len(t, state.Services, 3) + assert.Equal(t, "chat-bot", state.Services[0].Name) + assert.True(t, state.Services[0].IsDeployed) + assert.Equal(t, "echo", state.Services[1].Name) + assert.False(t, state.Services[1].IsDeployed) + assert.Equal(t, "my service", state.Services[2].Name) + assert.True(t, state.Services[2].IsDeployed) + }, + }, + { + name: "non-agent services are filtered out", + src: &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost}, + "web": {Name: "web", Host: "appservice"}, + "worker": {Name: "worker", Host: "containerapp"}, + }, + }, + values: map[string]string{ + "dev/AGENT_ECHO_VERSION": "1", + }, + }, + assert: func(t *testing.T, state *State, _ []error) { + require.Len(t, state.Services, 1) + assert.Equal(t, "echo", state.Services[0].Name) + assert.Equal(t, agentHost, state.Services[0].Host) + assert.True(t, state.Services[0].IsDeployed) + }, + }, + { + name: "transport error on env-value read does not abort assembly", + src: &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{"echo": {Name: "echo", Host: agentHost}}, + }, + valueErr: errors.New("gRPC unavailable"), + }, + assert: func(t *testing.T, state *State, _ []error) { + require.Len(t, state.Services, 1) + assert.False(t, state.Services[0].IsDeployed) + assert.False(t, state.HasProjectEndpoint) + assert.Empty(t, state.PendingProvisionReasons) + }, + // One error each for AZURE_AI_PROJECT_ENDPOINT, + // AI_AGENT_PENDING_PROVISION + one per service lookup + // (AGENT_ECHO_VERSION) = 3. + errCount: 3, + }, + { + name: "AI_AGENT_PENDING_PROVISION unset: PendingProvisionReasons stays empty", + src: &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{Name: "demo"}, + values: map[string]string{"dev/AZURE_AI_PROJECT_ENDPOINT": "https://x.services.ai.azure.com"}, + }, + assert: func(t *testing.T, state *State, _ []error) { + assert.Empty(t, state.PendingProvisionReasons) + }, + }, + { + name: "AI_AGENT_PENDING_PROVISION single tag: PendingProvisionReasons populated", + src: &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{Name: "demo"}, + values: map[string]string{ + "dev/AZURE_AI_PROJECT_ENDPOINT": "https://x.services.ai.azure.com", + "dev/AI_AGENT_PENDING_PROVISION": "model_deployment", + }, + }, + assert: func(t *testing.T, state *State, _ []error) { + assert.Equal(t, []string{"model_deployment"}, state.PendingProvisionReasons) + }, + }, + { + name: "AI_AGENT_PENDING_PROVISION multiple tags: parsed sorted dedup", + src: &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{Name: "demo"}, + values: map[string]string{ + "dev/AZURE_AI_PROJECT_ENDPOINT": "https://x.services.ai.azure.com", + "dev/AI_AGENT_PENDING_PROVISION": "project,acr,project,model_deployment", + }, + }, + assert: func(t *testing.T, state *State, _ []error) { + assert.Equal(t, []string{"acr", "model_deployment", "project"}, state.PendingProvisionReasons) + }, + }, + { + name: "AI_AGENT_PENDING_PROVISION malformed value: best-effort normalize", + src: &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{Name: "demo"}, + values: map[string]string{ + "dev/AZURE_AI_PROJECT_ENDPOINT": "https://x.services.ai.azure.com", + "dev/AI_AGENT_PENDING_PROVISION": " ,, project ,, acr , ", + }, + }, + assert: func(t *testing.T, state *State, _ []error) { + assert.Equal(t, []string{"acr", "project"}, state.PendingProvisionReasons) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + state, errs := assembleState(context.Background(), tt.src) + require.NotNil(t, state) + assert.Len(t, errs, tt.errCount) + tt.assert(t, state, errs) + }) + } +} + +func TestAssembleState_NilServiceEntriesAreIgnored(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "good": {Name: "good", Host: agentHost}, + "nil": nil, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + assert.Empty(t, errs) + require.Len(t, state.Services, 1) + assert.Equal(t, "good", state.Services[0].Name) +} + +func TestAgentHostConstant(t *testing.T) { + t.Parallel() + // agentHost must remain in sync with cmd.AiAgentHost ("azure.ai.agent"). + // Pinning the literal here guards against accidental drift while the + // duplication exists; Phase 2's nextstep wiring should retire it. + assert.Equal(t, "azure.ai.agent", agentHost) +} + +func TestServiceKey(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "echo": "ECHO", + "chat-bot": "CHAT_BOT", + "my service": "MY_SERVICE", + "Mixed-Case 1": "MIXED_CASE_1", + "": "", + } + for in, want := range tests { + assert.Equal(t, want, serviceKey(in), "serviceKey(%q)", in) + } +} + +func TestOptionsApplyCleanly(t *testing.T) { + t.Parallel() + + cfg := &config{} + WithOpenAPIProbe("echo", "local")(cfg) + WithLiveOpenAPIProbe(func(context.Context) ([]byte, error) { return nil, nil })(cfg) + assert.Equal(t, "echo", cfg.openAPIAgent) + assert.Equal(t, "local", cfg.openAPISuffix) + assert.NotNil(t, cfg.openAPILiveFetch) +} + +func TestWithOpenAPIProbe_EmptyArgsDisableProbe(t *testing.T) { + t.Parallel() + + cfg := &config{} + WithOpenAPIProbe("", "")(cfg) + assert.Empty(t, cfg.openAPIAgent) + assert.Empty(t, cfg.openAPISuffix) +} + +func TestAssembleState_WithOpenAPIProbe_PopulatesPayloadFromCache(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + configDir := filepath.Join(projectRoot, ".azure", "dev") + require.NoError(t, os.MkdirAll(configDir, 0o750)) + + spec := `{ + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "example": {"message": "ping"} + } + } + } + } + } + } + }` + require.NoError(t, os.WriteFile( + filepath.Join(configDir, "openapi-echo-local.json"), + []byte(spec), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost}, + }, + }, + } + + state, errs := assembleState(context.Background(), src, WithOpenAPIProbe("echo", "local")) + require.Empty(t, errs) + assert.True(t, state.HasOpenAPI) + assert.Equal(t, `{"message":"ping"}`, state.OpenAPIPayload) +} + +func TestAssembleState_WithOpenAPIProbe_MissingCacheLeavesPayloadUnset(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".azure", "dev"), 0o750)) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost}, + }, + }, + } + + state, errs := assembleState(context.Background(), src, WithOpenAPIProbe("echo", "local")) + require.Empty(t, errs) + assert.False(t, state.HasOpenAPI) + assert.Empty(t, state.OpenAPIPayload) +} + +func TestAssembleState_WithOpenAPIProbe_DisabledWhenAgentEmpty(t *testing.T) { + t.Parallel() + + // Lay down a spec that would otherwise be picked up — empty agentName + // must disable the probe so this cache is ignored. + projectRoot := t.TempDir() + configDir := filepath.Join(projectRoot, ".azure", "dev") + require.NoError(t, os.MkdirAll(configDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(configDir, "openapi-echo-local.json"), + []byte(`{"paths":{"/invocations":{"post":{"requestBody":{"content":{"application/json":{"example":{"x":1}}}}}}}}`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{Path: projectRoot}, + } + + state, errs := assembleState(context.Background(), src, WithOpenAPIProbe("", "local")) + require.Empty(t, errs) + assert.False(t, state.HasOpenAPI) + assert.Empty(t, state.OpenAPIPayload) +} + +func TestAssembleState_WithLiveOpenAPIProbe_PrefersLiveOverCache(t *testing.T) { + t.Parallel() + + // Put a "stale" payload in the on-disk cache. The live probe + // returns a different payload; the assembler must prefer the + // live result, proving the live probe takes precedence. + projectRoot := t.TempDir() + configDir := filepath.Join(projectRoot, ".azure", "dev") + require.NoError(t, os.MkdirAll(configDir, 0o750)) + stale := `{"paths":{"/invocations":{"post":{"requestBody":{"content":{"application/json":{"example":{"stale":true}}}}}}}}` + require.NoError(t, os.WriteFile( + filepath.Join(configDir, "openapi-echo-local.json"), + []byte(stale), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{"echo": {Name: "echo", Host: agentHost}}, + }, + } + + fresh := []byte(`{"paths":{"/invocations":{"post":{"requestBody":{"content":{"application/json":{"example":{"fresh":true}}}}}}}}`) + state, errs := assembleState( + context.Background(), + src, + WithOpenAPIProbe("echo", "local"), + WithLiveOpenAPIProbe(func(context.Context) ([]byte, error) { return fresh, nil }), + ) + require.Empty(t, errs) + assert.True(t, state.HasOpenAPI) + assert.Equal(t, `{"fresh":true}`, state.OpenAPIPayload) +} + +func TestAssembleState_WithLiveOpenAPIProbe_FallsBackToCacheOnError(t *testing.T) { + t.Parallel() + + // Live probe returns an error; the cache (when present and + // well-formed) must take over silently — the design budget for + // the live probe is 3 s and a failed fetch shouldn't deprive + // the user of the cached sample. + projectRoot := t.TempDir() + configDir := filepath.Join(projectRoot, ".azure", "dev") + require.NoError(t, os.MkdirAll(configDir, 0o750)) + cached := `{"paths":{"/invocations":{"post":{"requestBody":{"content":{"application/json":{"example":{"cached":true}}}}}}}}` + require.NoError(t, os.WriteFile( + filepath.Join(configDir, "openapi-echo-local.json"), + []byte(cached), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{"echo": {Name: "echo", Host: agentHost}}, + }, + } + + state, errs := assembleState( + context.Background(), + src, + WithOpenAPIProbe("echo", "local"), + WithLiveOpenAPIProbe(func(context.Context) ([]byte, error) { + return nil, errors.New("connection refused") + }), + ) + require.Empty(t, errs) + assert.True(t, state.HasOpenAPI) + assert.Equal(t, `{"cached":true}`, state.OpenAPIPayload) +} + +func TestAssembleState_WithLiveOpenAPIProbe_FallsBackToCacheOnEmptyBody(t *testing.T) { + t.Parallel() + + // Live probe returns nil bytes with no error (e.g., agent + // exposed /openapi.json but the body was empty after read). + // Treat identically to an error — empty body is unusable for + // example extraction and the cache must take over. + projectRoot := t.TempDir() + configDir := filepath.Join(projectRoot, ".azure", "dev") + require.NoError(t, os.MkdirAll(configDir, 0o750)) + cached := `{"paths":{"/invocations":{"post":{"requestBody":{"content":{"application/json":{"example":{"cached":true}}}}}}}}` + require.NoError(t, os.WriteFile( + filepath.Join(configDir, "openapi-echo-local.json"), + []byte(cached), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{"echo": {Name: "echo", Host: agentHost}}, + }, + } + + state, errs := assembleState( + context.Background(), + src, + WithOpenAPIProbe("echo", "local"), + WithLiveOpenAPIProbe(func(context.Context) ([]byte, error) { return nil, nil }), + ) + require.Empty(t, errs) + assert.True(t, state.HasOpenAPI) + assert.Equal(t, `{"cached":true}`, state.OpenAPIPayload) +} + +func TestAssembleState_WithLiveOpenAPIProbe_LiveWorksEvenWithoutCacheProbe(t *testing.T) { + t.Parallel() + + // The live probe must not require WithOpenAPIProbe to be set — + // `run` may surface a payload from the freshly-started agent + // even when no prior `invoke` has populated the on-disk cache. + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".azure", "dev"), 0o750)) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{"echo": {Name: "echo", Host: agentHost}}, + }, + } + + fresh := []byte(`{"paths":{"/invocations":{"post":{"requestBody":{"content":{"application/json":{"example":{"live":true}}}}}}}}`) + state, errs := assembleState( + context.Background(), + src, + WithLiveOpenAPIProbe(func(context.Context) ([]byte, error) { return fresh, nil }), + ) + require.Empty(t, errs) + assert.True(t, state.HasOpenAPI) + assert.Equal(t, `{"live":true}`, state.OpenAPIPayload) +} + +func TestAssembleState_WithLiveOpenAPIProbe_LiveFailureWithoutCacheLeavesUnset(t *testing.T) { + t.Parallel() + + // Live probe errors AND no cache present → resolver must fall + // back to the protocol-generic literal (HasOpenAPI=false). + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".azure", "dev"), 0o750)) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{"echo": {Name: "echo", Host: agentHost}}, + }, + } + + state, errs := assembleState( + context.Background(), + src, + WithOpenAPIProbe("echo", "local"), + WithLiveOpenAPIProbe(func(context.Context) ([]byte, error) { + return nil, errors.New("dial tcp: connection refused") + }), + ) + require.Empty(t, errs) + assert.False(t, state.HasOpenAPI) + assert.Empty(t, state.OpenAPIPayload) +} + +func TestLoadServiceProtocol(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifest string // raw agent.yaml content; empty string means "do not write the file" + manifestRel string // override relativePath in the call (for missing-dir cases) + want string + }{ + { + name: "single responses protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: responses + version: "1.0.0" +`, + want: ProtocolResponses, + }, + { + name: "single invocations protocol", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations + version: "1.0.0" +`, + want: ProtocolInvocations, + }, + { + name: "responses wins when both declared", + manifest: `kind: hostedAgent +protocols: + - protocol: invocations + version: "1.0.0" + - protocol: responses + version: "1.0.0" +`, + want: ProtocolResponses, + }, + { + name: "empty protocols section", + manifest: `kind: hostedAgent +protocols: [] +`, + want: "", + }, + { + name: "unknown protocol value silently ignored", + manifest: `kind: hostedAgent +protocols: + - protocol: pigeon-mail + version: "1.0.0" +`, + want: "", + }, + { + name: "malformed yaml returns empty", + manifest: "this: is: not: valid: yaml: at: all: [", + want: "", + }, + { + name: "missing file returns empty", + manifestRel: "does-not-exist", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + projectRoot := t.TempDir() + relPath := "echo" + if tt.manifestRel != "" { + relPath = tt.manifestRel + } else { + svcDir := filepath.Join(projectRoot, relPath) + require.NoError(t, os.MkdirAll(svcDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(svcDir, "agent.yaml"), + []byte(tt.manifest), + 0o600, + )) + } + got := loadServiceProtocol(projectRoot, relPath) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestLoadServiceProtocol_EmptyArgs(t *testing.T) { + t.Parallel() + + assert.Equal(t, "", loadServiceProtocol("", "echo")) +} + +func TestLoadServiceProtocol_RootRelativePath(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "agent.yaml"), + []byte("kind: hostedAgent\nprotocols:\n - protocol: invocations\n version: \"1.0.0\"\n"), + 0o600, + )) + + assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, "")) + assert.Equal(t, ProtocolInvocations, loadServiceProtocol(projectRoot, ".")) +} + +func TestLoadServiceProtocol_RejectsTraversal(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + projectRoot := filepath.Join(parent, "project") + outside := filepath.Join(parent, "outside") + require.NoError(t, os.MkdirAll(projectRoot, 0o750)) + require.NoError(t, os.MkdirAll(outside, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(outside, "agent.yaml"), + []byte("kind: hostedAgent\nprotocols:\n - protocol: invocations\n version: \"1.0.0\"\n"), + 0o600, + )) + + assert.Equal(t, "", loadServiceProtocol(projectRoot, "../outside")) +} + +func TestAssembleState_PopulatesProtocolFromAgentYaml(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte("kind: hostedAgent\nprotocols:\n - protocol: invocations\n version: \"1.0.0\"\n"), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + require.Len(t, state.Services, 1) + assert.Equal(t, ProtocolInvocations, state.Services[0].Protocol) +} + +func TestExtractAgentYamlEnvRefs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifest string + wantRefs []string + wantPlaceholders []string + }{ + { + name: "single bare reference", + manifest: `kind: hostedAgent +environment_variables: + - name: ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} +`, + wantRefs: []string{"AZURE_AI_PROJECT_ENDPOINT"}, + }, + { + name: "reference with default tail is skipped", + manifest: `kind: hostedAgent +environment_variables: + - name: MODEL + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME:-gpt-4o-mini} +`, + wantRefs: nil, + }, + { + name: "bare ref alongside defaulted ref returns only the bare one", + manifest: `kind: hostedAgent +environment_variables: + - name: ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: MODEL + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME:-gpt-4o-mini} +`, + wantRefs: []string{"AZURE_AI_PROJECT_ENDPOINT"}, + }, + { + name: "multiple references in one value", + manifest: `kind: hostedAgent +environment_variables: + - name: CONN + value: postgresql://${DB_HOST}:5432/${DB_NAME} +`, + wantRefs: []string{"DB_HOST", "DB_NAME"}, + }, + { + name: "duplicate references deduplicated by first appearance", + manifest: `kind: hostedAgent +environment_variables: + - name: A + value: ${X}-${X} + - name: B + value: ${X} +`, + wantRefs: []string{"X"}, + }, + { + name: "no environment_variables block", + manifest: `kind: hostedAgent +protocols: + - protocol: responses + version: "1.0.0" +`, + wantRefs: nil, + }, + { + name: "literal value with no ${} reference", + manifest: `kind: hostedAgent +environment_variables: + - name: STATIC + value: hardcoded +`, + wantRefs: nil, + }, + { + name: "malformed yaml returns nil", + manifest: "this: is: not: valid: yaml: at: all: [", + wantRefs: nil, + }, + { + name: "mustache placeholder surfaced separately", + manifest: `kind: hostedAgent +environment_variables: + - name: TOOLBOX_ENDPOINT + value: '{{TOOLBOX_ENDPOINT}}' +`, + wantPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + }, + { + name: "mustache placeholder with internal whitespace", + manifest: `kind: hostedAgent +environment_variables: + - name: KEY + value: '{{ MY_KEY }}' +`, + wantPlaceholders: []string{"MY_KEY"}, + }, + { + name: "duplicate placeholders deduplicated", + manifest: `kind: hostedAgent +environment_variables: + - name: A + value: '{{X}}-{{X}}' + - name: B + value: '{{X}}' +`, + wantPlaceholders: []string{"X"}, + }, + { + name: "ref and placeholder coexist in same manifest", + manifest: `kind: hostedAgent +environment_variables: + - name: TOOLBOX_ENDPOINT + value: '{{TOOLBOX_ENDPOINT}}' + - name: MCP_ENDPOINT + value: ${TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT} +`, + wantRefs: []string{"TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT"}, + wantPlaceholders: []string{"TOOLBOX_ENDPOINT"}, + }, + { + // Manifest parameter names are not constrained to env-var + // identifier shape — parameters.go:injectParameterValues + // substitutes the raw YAML key without validating it. + // A surviving `{{toolbox-endpoint}}` (hyphen) must therefore + // still be flagged or the user gets no Next: hint. + name: "mustache placeholder with hyphen in name", + manifest: `kind: hostedAgent +environment_variables: + - name: TOOLBOX_ENDPOINT + value: '{{toolbox-endpoint}}' +`, + wantPlaceholders: []string{"toolbox-endpoint"}, + }, + { + name: "mustache placeholder with dot in name", + manifest: `kind: hostedAgent +environment_variables: + - name: COMPONENT + value: '{{my.component.id}}' +`, + wantPlaceholders: []string{"my.component.id"}, + }, + { + // Empty placeholder body must not be flagged — it cannot + // correspond to a manifest parameter and is more likely + // stray literal text. + name: "empty mustache braces are ignored", + manifest: `kind: hostedAgent +environment_variables: + - name: NOISE + value: 'preamble {{}} suffix' +`, + wantPlaceholders: nil, + }, + { + // Whitespace-only placeholder body is similarly garbage — + // must not be flagged. + name: "whitespace-only mustache braces are ignored", + manifest: `kind: hostedAgent +environment_variables: + - name: NOISE + value: 'preamble {{ }} suffix' +`, + wantPlaceholders: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + projectRoot := t.TempDir() + svcDir := filepath.Join(projectRoot, "echo") + require.NoError(t, os.MkdirAll(svcDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(svcDir, "agent.yaml"), + []byte(tt.manifest), + 0o600, + )) + gotRefs, gotPlaceholders := extractAgentYamlEnvRefs(projectRoot, "echo") + assert.Equal(t, tt.wantRefs, gotRefs, "refs") + assert.Equal(t, tt.wantPlaceholders, gotPlaceholders, "placeholders") + }) + } +} + +func TestExtractAgentYamlEnvRefs_MissingFileOrArgs(t *testing.T) { + t.Parallel() + + for _, args := range [][2]string{ + {"", "echo"}, + {t.TempDir(), ""}, + {t.TempDir(), "missing"}, + } { + refs, placeholders := extractAgentYamlEnvRefs(args[0], args[1]) + assert.Nil(t, refs) + assert.Nil(t, placeholders) + } +} + +func TestExtractAgentYamlEnvRefs_RejectsTraversal(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + projectRoot := filepath.Join(parent, "project") + outside := filepath.Join(parent, "outside") + require.NoError(t, os.MkdirAll(projectRoot, 0o750)) + require.NoError(t, os.MkdirAll(outside, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(outside, "agent.yaml"), + []byte("kind: hostedAgent\nenvironment_variables:\n - name: SECRET\n value: ${OUTSIDE_SECRET}\n"), + 0o600, + )) + + refs, placeholders := extractAgentYamlEnvRefs(projectRoot, "../outside") + + assert.Nil(t, refs) + assert.Nil(t, placeholders) +} + +func TestExtractAgentYamlEnvRefs_RootRelativePath(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "agent.yaml"), + []byte("kind: hostedAgent\nenvironment_variables:\n - name: SECRET\n value: ${ROOT_SECRET}\n"), + 0o600, + )) + + for _, rel := range []string{"", "."} { + refs, placeholders := extractAgentYamlEnvRefs(projectRoot, rel) + + assert.Equal(t, []string{"ROOT_SECRET"}, refs) + assert.Nil(t, placeholders) + } +} + +func TestAssembleState_PopulatesMissingVars(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: MODEL + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: KEY + value: ${MY_API_KEY} + - name: STATIC + value: hardcoded +`), + 0o600, + )) + // infra/main.bicep declares both AZURE_* names as outputs, so they + // route to MissingInfraVars when unset. MY_API_KEY has no Bicep + // output and routes to MissingManualVars. + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte(`output AZURE_AI_PROJECT_ENDPOINT string = '' +output AZURE_AI_MODEL_DEPLOYMENT_NAME string = '' +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + values: map[string]string{ + // AZURE_AI_MODEL_DEPLOYMENT_NAME is set; the other two are not. + "dev/AZURE_AI_MODEL_DEPLOYMENT_NAME": "gpt-4o-mini", + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Equal(t, []string{"AZURE_AI_PROJECT_ENDPOINT"}, state.MissingInfraVars) + assert.Equal(t, []string{"MY_API_KEY"}, state.MissingManualVars) +} + +func TestAssembleState_MissingVarsDedupedAcrossServices(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + manifest := []byte(`kind: hostedAgent +environment_variables: + - name: ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: KEY + value: ${MY_API_KEY} +`) + for _, rel := range []string{"echo", "ping"} { + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, rel), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, rel, "agent.yaml"), + manifest, + 0o600, + )) + } + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte(`output AZURE_AI_PROJECT_ENDPOINT string = '' +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + "ping": {Name: "ping", Host: agentHost, RelativePath: "ping"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Equal(t, []string{"AZURE_AI_PROJECT_ENDPOINT"}, state.MissingInfraVars) + assert.Equal(t, []string{"MY_API_KEY"}, state.MissingManualVars) +} + +func TestAssembleState_AllVarsSetLeavesMissingEmpty(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: KEY + value: ${MY_API_KEY} +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + values: map[string]string{ + "dev/AZURE_AI_PROJECT_ENDPOINT": "https://x.services.ai.azure.com", + "dev/MY_API_KEY": "sk-abc", + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Empty(t, state.MissingInfraVars) + assert.Empty(t, state.MissingManualVars) +} + +func TestAssembleState_DefaultedRefsAreExcludedFromMissingVars(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + // Both refs use POSIX ${VAR:-default} syntax; the deploy-time expander + // honors the default so neither variable is required. The bare-form + // ENDPOINT ref is unset and IS required, so it still surfaces. + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: MODEL + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME:-gpt-4o-mini} + - name: KEY + value: ${MY_API_KEY:-dev-fallback} +`), + 0o600, + )) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte(`output AZURE_AI_PROJECT_ENDPOINT string = '' +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + // Intentionally leave AZURE_AI_MODEL_DEPLOYMENT_NAME and MY_API_KEY + // unset; defaulted refs must not surface them as missing. + values: map[string]string{}, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Equal(t, []string{"AZURE_AI_PROJECT_ENDPOINT"}, state.MissingInfraVars) + assert.Empty(t, state.MissingManualVars) +} + +func TestAssembleState_MissingVarTransportErrorSurfaced(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: KEY + value: ${MY_API_KEY} +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + valueErr: errors.New("gRPC unavailable"), + } + + state, errs := assembleState(context.Background(), src) + // One error each for AZURE_AI_PROJECT_ENDPOINT + + // AI_AGENT_PENDING_PROVISION + AGENT_ECHO_VERSION + MY_API_KEY = 4. + assert.Len(t, errs, 4) + assert.Empty(t, state.MissingInfraVars) + assert.Empty(t, state.MissingManualVars) +} + +func TestAssembleState_PopulatesUnresolvedPlaceholders(t *testing.T) { + t.Parallel() + + // Reproduces the toolbox-sample bug: agent.manifest.yaml processing + // leaves a {{NAME}} placeholder behind in agent.yaml, while a separate + // env var ref is also unset. The resolver should see both. + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: TOOLBOX_ENDPOINT + value: '{{TOOLBOX_ENDPOINT}}' + - name: MCP_ENDPOINT + value: ${TOOLBOX_MCP_ENDPOINT} +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Empty(t, state.MissingInfraVars) + assert.Equal(t, []string{"TOOLBOX_MCP_ENDPOINT"}, state.MissingManualVars) + assert.Equal(t, []string{"TOOLBOX_ENDPOINT"}, state.UnresolvedPlaceholders) +} + +func TestAssembleState_PlaceholdersDedupedAcrossServices(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + manifest := []byte(`kind: hostedAgent +environment_variables: + - name: A + value: '{{SHARED_PLACEHOLDER}}' +`) + for _, rel := range []string{"echo", "ping"} { + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, rel), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, rel, "agent.yaml"), + manifest, + 0o600, + )) + } + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + "ping": {Name: "ping", Host: agentHost, RelativePath: "ping"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Equal(t, []string{"SHARED_PLACEHOLDER"}, state.UnresolvedPlaceholders) +} + +// TestAssembleState_NonAzurePrefixBicepOutputIsInfra is the B1 fix proof. +// It locks issue #7975 State Inputs line 74 ("HasUnresolvedInfraVars = +// agent.yaml ${VAR} refs that map to known Bicep outputs are unset in +// azd env"). Pre-C1, the resolver split on the AZURE_ prefix; this +// test guarantees the new classifier is set-membership based and +// correctly routes a non-AZURE_ Bicep output to MissingInfraVars. +func TestAssembleState_NonAzurePrefixBicepOutputIsInfra(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: TOOLBOX + value: ${TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT} + - name: BING + value: ${BING_GROUNDING_CONNECTION_ID} + - name: KEY + value: ${MY_API_KEY} +`), + 0o600, + )) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte(`output TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT string = '' +output BING_GROUNDING_CONNECTION_ID string = '' +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Equal( + t, + []string{"BING_GROUNDING_CONNECTION_ID", "TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT"}, + state.MissingInfraVars, + ) + assert.Equal(t, []string{"MY_API_KEY"}, state.MissingManualVars) +} + +// TestAssembleState_NoBicepFileEverythingManual locks the conservative +// fallback: when infra/main.bicep is missing, every unset ref lands in +// MissingManualVars. Notably this includes AZURE_*-prefixed names — +// without the prefix shortcut, AZURE_ has no special meaning anymore. +func TestAssembleState_NoBicepFileEverythingManual(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: TOOLBOX + value: ${TOOLBOX_MCP_ENDPOINT} + - name: KEY + value: ${MY_API_KEY} +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Empty(t, state.MissingInfraVars) + assert.Equal( + t, + []string{"AZURE_AI_PROJECT_ENDPOINT", "MY_API_KEY", "TOOLBOX_MCP_ENDPOINT"}, + state.MissingManualVars, + ) +} + +// TestAssembleState_DeclaredAndSetBicepOutputNotSurfaced locks the +// sanity case: a ref that maps to a Bicep output AND is set in the +// current env is not missing from either bucket. +func TestAssembleState_DeclaredAndSetBicepOutputNotSurfaced(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: TOOLBOX + value: ${TOOLBOX_MCP_ENDPOINT} +`), + 0o600, + )) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte(`output TOOLBOX_MCP_ENDPOINT string = '' +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + values: map[string]string{ + "dev/TOOLBOX_MCP_ENDPOINT": "https://mcp.example/x", + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Empty(t, state.MissingInfraVars) + assert.Empty(t, state.MissingManualVars) +} + +// TestAssembleState_UndeclaredRefIsManualEvenWithBicepFile locks the +// other half of set-membership classification: when infra/main.bicep +// exists but does NOT declare a ref'd var, the var lands in +// MissingManualVars (not MissingInfraVars). +func TestAssembleState_UndeclaredRefIsManualEvenWithBicepFile(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "echo"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "echo", "agent.yaml"), + []byte(`kind: hostedAgent +environment_variables: + - name: KEY + value: ${MY_API_KEY} +`), + 0o600, + )) + // Bicep file exists but doesn't declare MY_API_KEY → manual. + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte(`output AZURE_AI_PROJECT_ENDPOINT string = '' +`), + 0o600, + )) + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "echo": {Name: "echo", Host: agentHost, RelativePath: "echo"}, + }, + }, + } + + state, errs := assembleState(context.Background(), src) + require.Empty(t, errs) + assert.Empty(t, state.MissingInfraVars) + assert.Equal(t, []string{"MY_API_KEY"}, state.MissingManualVars) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go new file mode 100644 index 00000000000..358b8019650 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package nextstep computes and renders the context-aware "Next:" guidance +// block that azure.ai.agents commands surface at the end of successful (and +// some failing) runs. +// +// The package is split into three concerns: +// +// - State assembly (state.go) — collects everything resolvers may need +// into a single immutable snapshot; partial state never silences +// guidance. +// - Resolvers (resolver.go) — pure functions over *State that return a +// ranked []Suggestion for each command's exit path. +// - Formatters (format.go) — render []Suggestion either to a writer +// (PrintNext) or as a string suitable for embedding in an artifact's +// Metadata["note"] (FormatNextForNote). +// +// Output discipline lives at the call sites: the package never writes to +// os.Stdout directly and never inspects --output flags. Callers gate on +// the isTerminal helper / output mode and choose the writer or JSON +// envelope field accordingly. +package nextstep + +// Suggestion is a single line of next-step guidance: a command to run plus +// a one-line description. Suggestions are sorted ascending by Priority +// before rendering (lower = earlier; ties preserve input order). +// +// Trailing flags a "footer" suggestion that the renderer reserves a slot +// for even when higher-priority primary suggestions would otherwise fill +// the visible block. Used for follow-up nudges (e.g., the `azd deploy` +// line that ResolveAfterInit appends after the primary action) so the +// follow-up survives truncation. At most one trailing entry is rendered +// per block; when multiple Trailing-flagged entries are passed, the +// entry with the highest Priority wins — by convention footers are the +// most-deferred entries, so the most-deferred survives. +type Suggestion struct { + Command string + Description string + Priority int + Trailing bool +} + +// State is the snapshot resolvers operate on. AssembleState builds one per +// call; there is no shared singleton or cross-command cache. Fields +// marked optional below are populated only by the resolver paths that +// need them — see field docs. +type State struct { + // HasProjectEndpoint reports whether AZURE_AI_PROJECT_ENDPOINT is set + // (and non-empty) in the active azd environment. + HasProjectEndpoint bool + + // PendingProvisionReasons lists the resource-class tags that + // `azd ai agent init` configured but Azure has not yet + // materialized. Init code paths append a tag — e.g. + // "model_deployment" when a new model deployment is configured in + // an existing project, "project" when a new Foundry project is + // selected, "acr"/"app_insights" when the user leaves those + // inputs blank — and the postprovisionHandler clears the list on + // successful provision. The resolver fires `azd provision` + // whenever the list is non-empty; doctor can surface the specific + // reasons for richer diagnostics. + // + // The signal is stored in the AI_AGENT_PENDING_PROVISION env var + // (extension-owned namespace, not AZURE_*) as a comma-separated, + // sorted, deduplicated string. Unknown tags are tolerated by the + // resolver for forward-compatibility, so new init sites can + // introduce new tags without coordinating with this package. See + // pending_provision.go for the read/write helpers and the + // reason-tag taxonomy. + PendingProvisionReasons []string + + // MissingInfraVars names ${...} references in agent.yaml that map to + // Bicep outputs not yet present in the azd environment (i.e., + // provision is needed or has been skipped). Named so the resolver can + // surface an actionable hint. + MissingInfraVars []string + + // MissingManualVars names ${...} references that map to user-supplied + // variables which are not set in the azd environment. + MissingManualVars []string + + // UnresolvedPlaceholders names {{NAME}} Mustache-style placeholders + // still present (literally) inside agent.yaml's environment_variables + // values. These are left over from init's manifest processing when + // agent.manifest.yaml declares a placeholder without a matching + // parameter (or the user skipped the prompt). Unlike Missing*Vars, + // these cannot be supplied via `azd env set` — the literal `{{X}}` + // would still be in agent.yaml at deploy time. The resolver surfaces + // a distinct "edit agent.yaml" suggestion for each. + UnresolvedPlaceholders []string + + // Services is the per-service snapshot derived from azure.yaml plus + // the azd environment (for IsDeployed). + Services []ServiceState + + // AgentStatus is the remote agent version status as reported by the + // Foundry API (e.g., "Active", "Creating", "Failed"). Empty when the + // caller did not probe the remote API. + AgentStatus string + + // HasOpenAPI reports whether OpenAPIPayload has been populated. The + // payload is populated only when AssembleState is called from a path + // that contacts the agent (e.g., `run`, `doctor`). + HasOpenAPI bool + + // OpenAPIPayload is a sample request payload extracted from the + // agent's OpenAPI spec, suitable for an `azd ai agent invoke '...'` + // example. Empty when HasOpenAPI is false. + OpenAPIPayload string + + // HasModels, HasToolboxes, HasConnections are aggregate flags + // derived from each azure.ai.agent service's agent.manifest.yaml + // (when present). They are true when at least one resource of the + // matching kind is declared across all services. Doctor checks that + // only make sense in the presence of these resources gate-skip + // themselves on the matching Has* flag; resolvers can use them to + // tailor remediation suggestions. + // + // All three flags are false when the manifest file is missing, + // malformed, or declares no resources — the walker is deliberately + // silent on those failure modes so a missing/in-flight manifest + // never blocks the rest of state assembly. + HasModels bool + HasToolboxes bool + HasConnections bool + + // ModelRefs, Toolboxes, Connections list every resource of the + // matching kind found across all services' agent.manifest.yaml + // files. Entries are sorted by Name (ties broken by ServiceName) + // and deduplicated on (ServiceName, Name) so callers can render + // them deterministically. The slices are nil when the matching + // Has* flag is false. + ModelRefs []ResourceRef + Toolboxes []ResourceRef + Connections []ResourceRef +} + +// ResourceRef is a slim summary of a manifest resource that the +// nextstep package surfaces to doctor checks and resolvers. The +// shape intentionally elides agent_yaml.ModelResource / +// ToolboxResource / ConnectionResource details that doctor checks +// don't consume today — keeping the surface small so future +// manifest schema changes don't ripple through the resolver / doctor +// boundary. Add fields here only when a doctor check or resolver +// branch needs them. +type ResourceRef struct { + // Name is the resource's manifest-declared name (the `name:` + // field on the manifest's `resources[]` entry). Doctor checks + // match by this name when looking up Foundry deployments / + // connections / toolboxes. + Name string + + // ServiceName is the azd service that declared the resource (the + // service entry under `services:` in azure.yaml whose + // agent.manifest.yaml contains this entry). When the same logical + // resource is declared by multiple services they appear as + // separate entries — doctor checks key on (ServiceName, Name) so + // per-service failures are surfaced individually. + ServiceName string + + // Detail carries a kind-specific identifier: + // - models: ModelResource.Id (e.g., "azureml://...gpt-4o...") + // - connections: | + // - toolboxes: empty (no identifier beyond Name today) + // Doctor remediation messages render Detail verbatim, so changes + // here must match the doctor-message contract. + Detail string +} + +// ServiceState mirrors one entry from the project's services map, plus a +// deployment marker derived from azd environment variables. IsDeployed is +// true when AGENT__VERSION is non-empty in the active environment, +// where is the service name upper-cased with hyphens replaced by +// underscores — the convention used by the deploy-time env-var writer in +// project/service_target_agent.go. +type ServiceState struct { + Name string + Host string + Protocol string + RelativePath string + IsDeployed bool +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep_output.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep_output.go new file mode 100644 index 00000000000..6367a290c3b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep_output.go @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "io" + "os" + + "azureaiagent/internal/cmd/nextstep" +) + +// writerIsTerminal reports whether w is interactive OS stdout. +func writerIsTerminal(w io.Writer) bool { + return w == os.Stdout && stdoutIsTerminal() +} + +func stdoutIsTerminal() bool { + return isTerminal(os.Stdout.Fd()) +} + +func printNextIfTerminal(w io.Writer, suggestions []nextstep.Suggestion) error { + if len(suggestions) == 0 || !writerIsTerminal(w) { + return nil + } + + return nextstep.PrintNext(w, suggestions) +} + +func printAllNextIfTerminal(w io.Writer, suggestions []nextstep.Suggestion) error { + if len(suggestions) == 0 || !writerIsTerminal(w) { + return nil + } + + return nextstep.PrintAllNext(w, suggestions) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep_output_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep_output_test.go new file mode 100644 index 00000000000..14aaf759541 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep_output_test.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "testing" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriterIsTerminal_NonStdoutWriter(t *testing.T) { + t.Parallel() + + assert.False(t, writerIsTerminal(&bytes.Buffer{})) +} + +func TestPrintNextIfTerminal_SuppressesNonStdoutWriter(t *testing.T) { + t.Parallel() + + suggestions := []nextstep.Suggestion{ + {Command: "azd ai agent run", Description: "start locally"}, + } + + var buf bytes.Buffer + require.NoError(t, printNextIfTerminal(&buf, suggestions)) + require.NoError(t, printAllNextIfTerminal(&buf, suggestions)) + assert.Empty(t, buf.String()) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go new file mode 100644 index 00000000000..38285d40940 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// pendingProvisionEnvVar names the extension-owned env var that lists +// the resource-class tags `azd ai agent init` configured but Azure has +// not yet materialized. The variable is read by nextstep.AssembleState +// to populate State.PendingProvisionReasons; nextstep.ResolveAfterInit +// fires `azd provision` whenever the list is non-empty. +// +// Format: comma-separated, sorted, deduplicated tags. An empty or +// unset value means "no pending provision work". Unknown tags are +// tolerated by readers for forward-compatibility — new init code can +// introduce new tags without coordinating with the resolver. +// +// Lifecycle: +// - Init sites call addPendingProvisionReason as they configure +// each non-existent resource (model deployment, project, ACR, +// App Insights, …). +// - Init sites call removePendingProvisionReason when re-running +// init flips a previously-new resource back to "existing". +// - postprovisionHandler calls clearPendingProvisionReasons after a +// successful `azd provision` so subsequent invocations of doctor +// or the init trailer do not falsely suggest provision again. +const pendingProvisionEnvVar = "AI_AGENT_PENDING_PROVISION" + +// Known pending-provision reason tags. Adding a tag at an init site +// does not require a resolver change — the resolver treats the list +// as opaque and only checks for non-emptiness. Doctor and other +// readers can interpret tags for richer per-resource diagnostics. +const ( + pendingReasonProject = "project" + pendingReasonModelDeployment = "model_deployment" + pendingReasonACR = "acr" + pendingReasonAppInsights = "app_insights" +) + +// parsePendingProvisionReasons splits the comma-separated env-var +// value into a sorted, deduplicated, whitespace-trimmed slice. An +// empty input — or any input that contains only separators and +// whitespace — returns nil. Malformed inputs round-trip to a +// best-effort normalized form rather than failing; the env var is a +// hint signal, not a critical config value, and the caller's path +// should never abort on parse trouble. +func parsePendingProvisionReasons(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + seen := make(map[string]struct{}) + for raw := range strings.SplitSeq(value, ",") { + tag := strings.TrimSpace(raw) + if tag == "" { + continue + } + seen[tag] = struct{}{} + } + if len(seen) == 0 { + return nil + } + reasons := make([]string, 0, len(seen)) + for tag := range seen { + reasons = append(reasons, tag) + } + slices.Sort(reasons) + return reasons +} + +// formatPendingProvisionReasons joins a list of tags into the on-disk +// env-var format. The input may be unsorted or contain duplicates; +// the output is always sorted and deduplicated. An empty or +// all-empty input produces an empty string (which writers interpret +// as "clear the signal"). +func formatPendingProvisionReasons(reasons []string) string { + return strings.Join(parsePendingProvisionReasons(strings.Join(reasons, ",")), ",") +} + +// addPendingProvisionReason appends a reason tag to the +// AI_AGENT_PENDING_PROVISION env var if not already present. The +// read is best-effort: a missing variable (gRPC NotFound) is treated +// as an empty list. The write happens only when the resulting +// formatted value differs from what was on disk, so repeated calls +// for the same tag are cheap and idempotent. +// +// Returns the resulting (sorted, deduplicated) list for caller +// convenience; callers that only need the write effect can discard +// the slice. +func addPendingProvisionReason( + ctx context.Context, azdClient *azdext.AzdClient, envName, reason string, +) ([]string, error) { + return mutatePendingProvisionReasons(ctx, azdClient, envName, func(curr []string) []string { + if slices.Contains(curr, reason) { + return curr + } + return append(slices.Clone(curr), reason) + }) +} + +// removePendingProvisionReason drops a reason tag from the +// AI_AGENT_PENDING_PROVISION env var. Idempotent: removing a tag +// that was not present is a no-op (no write performed). Used when +// re-running init swaps an "existing resource" pick into a slot +// that previously held a "new resource" pick, so the trailer does +// not keep showing a stale "needs provision" for that class. +func removePendingProvisionReason( + ctx context.Context, azdClient *azdext.AzdClient, envName, reason string, +) ([]string, error) { + return mutatePendingProvisionReasons(ctx, azdClient, envName, func(curr []string) []string { + out := make([]string, 0, len(curr)) + for _, tag := range curr { + if tag != reason { + out = append(out, tag) + } + } + return out + }) +} + +// clearPendingProvisionReasons wipes the AI_AGENT_PENDING_PROVISION +// env var. Called by postprovisionHandler after a successful +// provision so the resolver no longer suggests `azd provision` +// against a now-stale signal. Writing the empty string (rather than +// deleting the key) is consistent with the rest of the extension +// and round-trips through the gRPC SetValue API. +func clearPendingProvisionReasons( + ctx context.Context, azdClient *azdext.AzdClient, envName string, +) error { + return setEnvValue(ctx, azdClient, envName, pendingProvisionEnvVar, "") +} + +// readPendingProvisionEnv reads the AI_AGENT_PENDING_PROVISION env +// var. Production `environmentService.GetValue` +// (cli/azd/internal/grpcserver/environment_service.go) returns +// `{Value: ""}` with a nil error for unset keys — never NotFound — +// so the empty-string fast path is what actually runs in practice. +// The `codes.NotFound` branch below exists for two reasons: +// (1) the test fixture `testEnvironmentServiceServer.GetValue` +// returns NotFound for absent keys, so the branch is exercised by +// unit tests; (2) defensive parity with potential future env-service +// semantics. Any other transport error is surfaced with a wrapped +// context so callers can decide whether to fail or fall back to an +// empty list. +func readPendingProvisionEnv( + ctx context.Context, azdClient *azdext.AzdClient, envName string, +) (string, error) { + resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: pendingProvisionEnvVar, + }) + if err != nil { + if status.Code(err) == codes.NotFound { + return "", nil + } + return "", fmt.Errorf("failed to read %s: %w", pendingProvisionEnvVar, err) + } + if resp == nil { + return "", nil + } + return resp.Value, nil +} + +// mutatePendingProvisionReasons is the shared read-modify-write +// helper for addPendingProvisionReason and +// removePendingProvisionReason. The caller supplies a pure function +// that transforms the current parsed list into the desired list. +// The helper handles parse normalization, equality detection (to +// avoid redundant writes), and error wrapping. +func mutatePendingProvisionReasons( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + mutate func(curr []string) []string, +) ([]string, error) { + priorRaw, err := readPendingProvisionEnv(ctx, azdClient, envName) + if err != nil { + return nil, err + } + curr := parsePendingProvisionReasons(priorRaw) + next := parsePendingProvisionReasons(formatPendingProvisionReasons(mutate(curr))) + formatted := strings.Join(next, ",") + if formatted == priorRaw { + return next, nil + } + if err := setEnvValue(ctx, azdClient, envName, pendingProvisionEnvVar, formatted); err != nil { + return nil, err + } + return next, nil +} + +// updatePendingModelDeploymentSignal centralizes the decision rule +// for the "model_deployment" tag in AI_AGENT_PENDING_PROVISION. +// It is called from both ProcessModels (manifest-driven init path) +// and init_from_code (code-discovery init path) so the signal +// semantics stay in one place. +// +// Rules: +// - anyModelProcessed=false → no-op. A flow that did not configure +// any model resources should not touch the signal (other tags, +// other init runs, or doctor's manual env edits must be +// preserved). +// - anyModelProcessed=true, anyNew=true → add "model_deployment". +// At least one configured model needs Azure to provision a new +// deployment. +// - anyModelProcessed=true, anyNew=false → remove "model_deployment". +// Every configured model points at an existing Azure deployment, +// so any prior "needs provision" hint from a previous init is +// stale. +// +// Errors are surfaced for callers to log; this function does not log +// directly so callers can adapt the message to their context (the +// interactive init flows currently use `log.Printf` with a "warning:" +// prefix). The signal is best-effort by design. +func updatePendingModelDeploymentSignal( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + anyModelProcessed bool, + anyNew bool, +) error { + if !anyModelProcessed { + return nil + } + if anyNew { + _, err := addPendingProvisionReason(ctx, azdClient, envName, pendingReasonModelDeployment) + return err + } + _, err := removePendingProvisionReason(ctx, azdClient, envName, pendingReasonModelDeployment) + return err +} + +// updatePendingProjectSignal centralizes the decision rule for the +// "project" tag in AI_AGENT_PENDING_PROVISION. It is called from every +// init.go branch that writes the USE_EXISTING_AI_PROJECT env var so +// the producer of the Bicep "skip project creation" signal and the +// producer of the trailer "needs provision" signal stay in sync. +// +// Rules: +// - useExisting=true → remove "project". The user picked an +// existing Foundry project; its endpoint and related vars were +// populated immediately at init time, so a prior init run's +// "project" hint (if any) is now stale. +// - useExisting=false → add "project". The user opted to create a +// new Foundry project, which requires `azd provision` to run +// before AZURE_AI_PROJECT_ENDPOINT reflects a real resource. +// +// Errors are surfaced for callers to log; this helper does not log +// directly so each call site can attach its own context. The signal +// is best-effort by design. +func updatePendingProjectSignal( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + useExisting bool, +) error { + if useExisting { + _, err := removePendingProvisionReason(ctx, azdClient, envName, pendingReasonProject) + return err + } + _, err := addPendingProvisionReason(ctx, azdClient, envName, pendingReasonProject) + return err +} + +// updatePendingACRSignal centralizes the decision rule for the "acr" +// tag in AI_AGENT_PENDING_PROVISION. It is called from each branch +// of configureAcrConnection so the trailer correctly tracks whether +// the ACR will be provisioned by `azd provision` or already exists. +// +// Rules: +// - present=true → remove "acr". The user either selected an +// existing ACR connection from the Foundry project or typed an +// existing login server at the empty-connections prompt; the +// resulting AZURE_CONTAINER_REGISTRY_* env vars point at a real +// resource so any prior init's "acr" hint is now stale. +// - present=false → add "acr". The user accepted the +// create-on-provision fallback (empty input at the +// empty-connections prompt); main.bicep will create the ACR +// when `azd provision` runs. +// +// Errors are surfaced for callers to log; this helper does not log +// directly. The signal is best-effort by design. +func updatePendingACRSignal( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + present bool, +) error { + if present { + _, err := removePendingProvisionReason(ctx, azdClient, envName, pendingReasonACR) + return err + } + _, err := addPendingProvisionReason(ctx, azdClient, envName, pendingReasonACR) + return err +} + +// updatePendingAppInsightsSignal centralizes the decision rule for +// the "app_insights" tag in AI_AGENT_PENDING_PROVISION. It is called +// from each branch of configureAppInsightsConnection so the trailer +// correctly tracks whether Application Insights will be provisioned +// by `azd provision` or already exists. +// +// Rules: +// - present=true → remove "app_insights". The user either selected +// an existing AppInsights connection from the Foundry project or +// typed an existing resource ID at the empty-connections prompt; +// the resulting APPLICATIONINSIGHTS_* env vars point at a real +// resource so any prior init's "app_insights" hint is now stale. +// - present=false → add "app_insights". The user accepted the +// create-on-provision fallback (empty input at the +// empty-connections prompt); main.bicep will create the +// Application Insights resource when `azd provision` runs. +// +// Errors are surfaced for callers to log; this helper does not log +// directly. The signal is best-effort by design. +func updatePendingAppInsightsSignal( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + present bool, +) error { + if present { + _, err := removePendingProvisionReason(ctx, azdClient, envName, pendingReasonAppInsights) + return err + } + _, err := addPendingProvisionReason(ctx, azdClient, envName, pendingReasonAppInsights) + return err +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go new file mode 100644 index 00000000000..569ae089302 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go @@ -0,0 +1,596 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestParsePendingProvisionReasons(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want []string + }{ + {"empty", "", nil}, + {"whitespace only", " ", nil}, + {"single", "project", []string{"project"}}, + {"single trimmed", " project ", []string{"project"}}, + {"multiple sorted", "project,model_deployment", []string{"model_deployment", "project"}}, + {"duplicates", "project,project,model_deployment", []string{"model_deployment", "project"}}, + {"with empty segments", "project,,model_deployment,", []string{"model_deployment", "project"}}, + {"all empty segments", ",,,", nil}, + {"with whitespace segments", " ,project , ,model_deployment ", []string{"model_deployment", "project"}}, + {"unknown tag preserved", "future_tag,project", []string{"future_tag", "project"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, parsePendingProvisionReasons(tc.in)) + }) + } +} + +func TestFormatPendingProvisionReasons(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in []string + want string + }{ + {"nil", nil, ""}, + {"empty", []string{}, ""}, + {"single", []string{"project"}, "project"}, + {"sorts and dedups", []string{"project", "acr", "project"}, "acr,project"}, + {"trims whitespace", []string{" project ", "acr"}, "acr,project"}, + {"all empty", []string{"", " "}, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, formatPendingProvisionReasons(tc.in)) + }) + } +} + +func TestAddPendingProvisionReason(t *testing.T) { + t.Parallel() + + t.Run("adds to empty env var", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := addPendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonModelDeployment) + require.NoError(t, err) + require.Equal(t, []string{pendingReasonModelDeployment}, out) + require.Equal(t, pendingReasonModelDeployment, envServer.values["test-env"][pendingProvisionEnvVar]) + }) + + t.Run("appends to existing list", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "project"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := addPendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonACR) + require.NoError(t, err) + require.Equal(t, []string{pendingReasonACR, "project"}, out) + require.Equal(t, "acr,project", envServer.values["test-env"][pendingProvisionEnvVar]) + }) + + t.Run("duplicate is no-op", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "model_deployment,project"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := addPendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonProject) + require.NoError(t, err) + require.Equal(t, []string{pendingReasonModelDeployment, pendingReasonProject}, out) + // Value unchanged from initial state (round-trips through parse/format). + require.Equal(t, "model_deployment,project", envServer.values["test-env"][pendingProvisionEnvVar]) + }) + + t.Run("normalizes prior malformed value before adding", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: " project,,project ,"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := addPendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonModelDeployment) + require.NoError(t, err) + require.Equal(t, []string{pendingReasonModelDeployment, pendingReasonProject}, out) + require.Equal(t, "model_deployment,project", envServer.values["test-env"][pendingProvisionEnvVar]) + }) +} + +func TestRemovePendingProvisionReason(t *testing.T) { + t.Parallel() + + t.Run("removes existing tag", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "acr,model_deployment,project"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := removePendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonModelDeployment) + require.NoError(t, err) + require.Equal(t, []string{pendingReasonACR, pendingReasonProject}, out) + require.Equal(t, "acr,project", envServer.values["test-env"][pendingProvisionEnvVar]) + }) + + t.Run("removing non-existent tag is no-op", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "project"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := removePendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonACR) + require.NoError(t, err) + require.Equal(t, []string{pendingReasonProject}, out) + require.Equal(t, "project", envServer.values["test-env"][pendingProvisionEnvVar]) + }) + + t.Run("removing from unset env var is no-op", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := removePendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonProject) + require.NoError(t, err) + require.Empty(t, out) + // No write should have happened — env var stays unset. + _, hit := envServer.values["test-env"][pendingProvisionEnvVar] + require.False(t, hit) + }) + + t.Run("removing last tag writes empty string", func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "project"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + out, err := removePendingProvisionReason(context.Background(), azdClient, "test-env", pendingReasonProject) + require.NoError(t, err) + require.Empty(t, out) + require.Equal(t, "", envServer.values["test-env"][pendingProvisionEnvVar]) + }) +} + +func TestClearPendingProvisionReasons(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + values: map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: "acr,model_deployment,project"}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + err := clearPendingProvisionReasons(context.Background(), azdClient, "test-env") + require.NoError(t, err) + require.Equal(t, "", envServer.values["test-env"][pendingProvisionEnvVar]) +} + +func TestUpdatePendingModelDeploymentSignal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + seed string // initial AI_AGENT_PENDING_PROVISION value + anyModelProcessed bool + anyNew bool + wantValue string // expected post-call value; "" with wantUnset=true means key absent + wantUnset bool + }{ + { + name: "no models processed: signal untouched (unset stays unset)", + anyModelProcessed: false, + anyNew: false, + wantUnset: true, + }, + { + name: "no models processed: signal untouched (existing value preserved)", + seed: "project,acr", + anyModelProcessed: false, + anyNew: false, + wantValue: "project,acr", + }, + { + name: "any new deployment: tag added", + anyModelProcessed: true, + anyNew: true, + wantValue: "model_deployment", + }, + { + name: "any new + existing tags: tag added without disturbing others", + seed: "project", + anyModelProcessed: true, + anyNew: true, + wantValue: "model_deployment,project", + }, + { + name: "any new + already-present tag: idempotent no rewrite", + seed: "model_deployment", + anyModelProcessed: true, + anyNew: true, + wantValue: "model_deployment", + }, + { + name: "all existing models: tag removed (was present)", + seed: "acr,model_deployment,project", + anyModelProcessed: true, + anyNew: false, + wantValue: "acr,project", + }, + { + name: "all existing models: tag-not-present is no-op", + seed: "project", + anyModelProcessed: true, + anyNew: false, + wantValue: "project", + }, + { + name: "all existing models: empty start stays empty", + anyModelProcessed: true, + anyNew: false, + wantUnset: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + } + if tc.seed != "" { + envServer.values = map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: tc.seed}, + } + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + err := updatePendingModelDeploymentSignal( + context.Background(), azdClient, "test-env", + tc.anyModelProcessed, tc.anyNew, + ) + require.NoError(t, err) + + if tc.wantUnset { + _, hit := envServer.values["test-env"][pendingProvisionEnvVar] + require.False(t, hit, "expected env var to remain unset") + return + } + require.Equal(t, tc.wantValue, envServer.values["test-env"][pendingProvisionEnvVar]) + }) + } +} + +func TestUpdatePendingProjectSignal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + seed string + useExisting bool + wantValue string + wantUnset bool + }{ + { + name: "existing project + empty seed: stays empty", + useExisting: true, + wantUnset: true, + }, + { + name: "existing project + tag present: tag removed", + seed: "model_deployment,project", + useExisting: true, + wantValue: "model_deployment", + }, + { + name: "existing project + tag absent: no-op", + seed: "model_deployment", + useExisting: true, + wantValue: "model_deployment", + }, + { + name: "new project + empty seed: tag added", + useExisting: false, + wantValue: "project", + }, + { + name: "new project + existing tags: tag added without disturbing others", + seed: "model_deployment", + useExisting: false, + wantValue: "model_deployment,project", + }, + { + name: "new project + tag already present: idempotent", + seed: "project", + useExisting: false, + wantValue: "project", + }, + { + // Drains the only remaining tag. The helper writes "" to the env + // var (key present in the map with empty value); it does NOT + // delete the key. Both forms parse to an empty reason set, but + // the assertion shape differs from the empty-seed cases above. + name: "existing project + tag is sole occupant: tag removed, env var emptied", + seed: "project", + useExisting: true, + wantValue: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + } + if tc.seed != "" { + envServer.values = map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: tc.seed}, + } + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + err := updatePendingProjectSignal(context.Background(), azdClient, "test-env", tc.useExisting) + require.NoError(t, err) + + if tc.wantUnset { + _, hit := envServer.values["test-env"][pendingProvisionEnvVar] + require.False(t, hit, "expected env var to remain unset") + return + } + require.Equal(t, tc.wantValue, envServer.values["test-env"][pendingProvisionEnvVar]) + }) + } +} + +func TestUpdatePendingACRSignal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + seed string + present bool + wantValue string + wantUnset bool + }{ + { + name: "existing ACR + empty seed: stays empty", + present: true, + wantUnset: true, + }, + { + name: "existing ACR + tag present: tag removed", + seed: "acr,project", + present: true, + wantValue: "project", + }, + { + name: "existing ACR + tag absent: no-op", + seed: "project", + present: true, + wantValue: "project", + }, + { + name: "no ACR + empty seed: tag added", + present: false, + wantValue: "acr", + }, + { + name: "no ACR + existing tags: tag added alongside", + seed: "model_deployment,project", + present: false, + wantValue: "acr,model_deployment,project", + }, + { + name: "no ACR + tag already present: idempotent", + seed: "acr", + present: false, + wantValue: "acr", + }, + { + // Drains the only remaining tag. Helper writes "" to env var + // (key present, empty value) rather than deleting it. + name: "existing ACR + tag is sole occupant: tag removed, env var emptied", + seed: "acr", + present: true, + wantValue: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + } + if tc.seed != "" { + envServer.values = map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: tc.seed}, + } + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + err := updatePendingACRSignal(context.Background(), azdClient, "test-env", tc.present) + require.NoError(t, err) + + if tc.wantUnset { + _, hit := envServer.values["test-env"][pendingProvisionEnvVar] + require.False(t, hit, "expected env var to remain unset") + return + } + require.Equal(t, tc.wantValue, envServer.values["test-env"][pendingProvisionEnvVar]) + }) + } +} + +func TestUpdatePendingAppInsightsSignal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + seed string + present bool + wantValue string + wantUnset bool + }{ + { + name: "existing AppInsights + empty seed: stays empty", + present: true, + wantUnset: true, + }, + { + name: "existing AppInsights + tag present: tag removed", + seed: "app_insights,project", + present: true, + wantValue: "project", + }, + { + name: "existing AppInsights + tag absent: no-op", + seed: "project", + present: true, + wantValue: "project", + }, + { + name: "no AppInsights + empty seed: tag added", + present: false, + wantValue: "app_insights", + }, + { + name: "no AppInsights + existing tags: tag added alongside", + seed: "acr,model_deployment,project", + present: false, + wantValue: "acr,app_insights,model_deployment,project", + }, + { + name: "no AppInsights + tag already present: idempotent", + seed: "app_insights", + present: false, + wantValue: "app_insights", + }, + { + // Drains the only remaining tag. Helper writes "" to env var + // (key present, empty value) rather than deleting it. + name: "existing AppInsights + tag is sole occupant: tag removed, env var emptied", + seed: "app_insights", + present: true, + wantValue: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + } + if tc.seed != "" { + envServer.values = map[string]map[string]string{ + "test-env": {pendingProvisionEnvVar: tc.seed}, + } + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + err := updatePendingAppInsightsSignal(context.Background(), azdClient, "test-env", tc.present) + require.NoError(t, err) + + if tc.wantUnset { + _, hit := envServer.values["test-env"][pendingProvisionEnvVar] + require.False(t, hit, "expected env var to remain unset") + return + } + require.Equal(t, tc.wantValue, envServer.values["test-env"][pendingProvisionEnvVar]) + }) + } +} + +func TestPendingProvisionRoundTrip(t *testing.T) { + t.Parallel() + + envServer := &testEnvironmentServiceServer{ + environments: map[string]*azdext.Environment{"test-env": {Name: "test-env"}}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + ctx := context.Background() + + // Sequence: add project → add model_deployment → add acr → remove + // project → clear. Verifies parse/format consistency, idempotence on + // duplicates, and end-state cleanliness. + _, err := addPendingProvisionReason(ctx, azdClient, "test-env", pendingReasonProject) + require.NoError(t, err) + _, err = addPendingProvisionReason(ctx, azdClient, "test-env", pendingReasonModelDeployment) + require.NoError(t, err) + _, err = addPendingProvisionReason(ctx, azdClient, "test-env", pendingReasonACR) + require.NoError(t, err) + require.Equal(t, "acr,model_deployment,project", envServer.values["test-env"][pendingProvisionEnvVar]) + + // Re-add a duplicate — value should be unchanged. + _, err = addPendingProvisionReason(ctx, azdClient, "test-env", pendingReasonACR) + require.NoError(t, err) + require.Equal(t, "acr,model_deployment,project", envServer.values["test-env"][pendingProvisionEnvVar]) + + _, err = removePendingProvisionReason(ctx, azdClient, "test-env", pendingReasonProject) + require.NoError(t, err) + require.Equal(t, "acr,model_deployment", envServer.values["test-env"][pendingProvisionEnvVar]) + + err = clearPendingProvisionReasons(ctx, azdClient, "test-env") + require.NoError(t, err) + require.Equal(t, "", envServer.values["test-env"][pendingProvisionEnvVar]) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index 06e20eea8cb..cef2bd54634 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -63,6 +63,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newFilesCommand(extCtx)) rootCmd.AddCommand(newSessionCommand(extCtx)) rootCmd.AddCommand(newSampleCommand(extCtx)) + rootCmd.AddCommand(newDoctorCommand()) // Connection commands — in separate package for easy lift-and-shift later. // When the azd core namespace change lands, move this AddCommand call diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index cf614e86b68..ef6edfe0fbc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -11,6 +11,7 @@ import ( "io" "log" "net" + "net/http" "os" "os/exec" "os/signal" @@ -22,6 +23,8 @@ import ( "syscall" "time" + "azureaiagent/internal/cmd/nextstep" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" "google.golang.org/grpc/codes" @@ -194,7 +197,16 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { } url := fmt.Sprintf("http://localhost:%d", flags.port) - fmt.Printf("\nStarting agent on %s (Ctrl+C to stop)\n\n", url) + + // `run` holds the foreground TTY for the agent process and the + // `Next:` block is a "wait + new terminal" sequence. Emitting it + // before the agent has actually bound its port produces the + // well-known race where a user alt-tabs to a fresh terminal and + // pastes the suggested invoke before the server is up — and the + // invoke fails. Defer the emission until net.DialTimeout against + // localhost:port succeeds (or the budget elapses). See B5 in the + // PR-8057 design spec. + fmt.Printf("Starting agent on %s (Ctrl+C to stop)\n\n", url) // Create command with stdout/stderr piped to terminal ctx, cancel := context.WithCancel(ctx) @@ -226,6 +238,20 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { os.Stderr, ) + // Emit the `Next:` block once the agent's port is open. We don't + // want users alt-tabbing to a fresh terminal and pasting the + // suggested invoke before the server is ready to answer. The + // goroutine returns silently if the agent never binds within the + // budget (e.g., the process exited during boot — the user already + // sees the stderr trace) or if the parent ctx is cancelled. + // nextDone signals the goroutine has exited so runRun can join it + // after proc.Wait returns, preventing stdout races on shutdown. + nextDone := make(chan struct{}) + go func() { + defer close(nextDone) + emitNextAfterBind(ctx, azdClient, runCtx.ServiceName, flags.port) + }() + // Handle Ctrl+C / SIGTERM: forward signal to child, then wait for it to exit. // The done channel is closed after proc.Wait returns so the goroutine can exit. sigCh := make(chan os.Signal, 1) @@ -243,6 +269,8 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { err = proc.Wait() close(done) + cancel() + <-nextDone // Suppress the noisy "signal: interrupt" error on Ctrl+C if ctx.Err() != nil { @@ -681,3 +709,122 @@ func loadAzdEnvironment(ctx context.Context, azdClient *azdext.AzdClient) (map[s } return result, nil } + +// emitNextAfterBind blocks until the agent process binds the local +// port (or the budget elapses, or ctx is cancelled) and then prints +// the protocol-appropriate `Next:` block. The state assembler is +// configured with both a live HTTP probe and the on-disk cache: the +// live spec wins when reachable, and the cache is the fallback when +// the agent doesn't expose /invocations/docs/openapi.json or fails +// its probe. +// +// Returns silently on every failure path (port never bound, ctx +// cancelled mid-wait, state assembly error, non-terminal stdout). The +// user already sees the agent's own stderr in those cases; surfacing +// additional diagnostics here would clutter an otherwise-busy terminal. +func emitNextAfterBind( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + port int, +) { + // Honor the nextstep call-site TTY-gating contract: when stdout + // is redirected (e.g., `azd ai agent run > log`), the human-only + // "Agent ready"/Next: block must not contaminate the capture. + if !stdoutIsTerminal() { + return + } + if !waitForPortReady(ctx, port, portReadyBudget) { + return + } + liveFetch := func(probeCtx context.Context) ([]byte, error) { + probeCtx, cancel := context.WithTimeout(probeCtx, liveOpenAPITimeout) + defer cancel() + return fetchLiveOpenAPI(probeCtx, port) + } + state, _ := nextstep.AssembleState(ctx, azdClient, + nextstep.WithOpenAPIProbe(serviceName, "local"), + nextstep.WithLiveOpenAPIProbe(liveFetch)) + // Re-check ctx after AssembleState: if Ctrl+C arrived mid-call, + // the user already saw "Stopping agent..."/"Agent stopped." and + // printing "Agent ready" now would be factually wrong. + if ctx.Err() != nil { + return + } + fmt.Println("\nAgent ready. In another terminal, try:") + _ = printNextIfTerminal(os.Stdout, nextstep.ResolveAfterRun(state, serviceName)) +} + +// portReadyBudget is the wall-clock ceiling for waitForPortReady; +// most agent runtimes (uvicorn, dotnet, node) bind within a second +// of start so 5 s is generous without making a failed boot drag +// the user's attention. +const portReadyBudget = 5 * time.Second + +// portReadyPollInterval is how often waitForPortReady probes the +// loopback address; 100 ms is short enough to feel snappy while +// keeping the wake-up count low on slow machines. +const portReadyPollInterval = 100 * time.Millisecond + +// portReadyDialTimeout caps each individual dial; this stays well +// below portReadyPollInterval so a slow refusal doesn't drag the +// poll cadence beyond the configured rhythm. +const portReadyDialTimeout = 50 * time.Millisecond + +// liveOpenAPITimeout caps the live /invocations/docs/openapi.json +// fetch issued by emitNextAfterBind. The design budget is 3 s — long +// enough for a freshly-bound server to honor the GET, short enough +// that a silent agent (no openapi route) doesn't visibly delay the +// `Next:` block. +const liveOpenAPITimeout = 3 * time.Second + +// waitForPortReady polls localhost:port at portReadyPollInterval +// until a TCP dial succeeds or the budget elapses. Returns true on +// success. Respects ctx.Done so a Ctrl+C during boot doesn't block +// the wait — the goroutine exits cleanly. +func waitForPortReady(ctx context.Context, port int, budget time.Duration) bool { + deadline := time.Now().Add(budget) + addr := fmt.Sprintf("localhost:%d", port) + for time.Now().Before(deadline) { + if ctx.Err() != nil { + return false + } + conn, err := net.DialTimeout("tcp", addr, portReadyDialTimeout) + if err == nil { + _ = conn.Close() + return true + } + select { + case <-ctx.Done(): + return false + case <-time.After(portReadyPollInterval): + } + } + return false +} + +// fetchLiveOpenAPI issues an HTTP GET against +// /invocations/docs/openapi.json on the local agent and returns the +// response body. The route matches the cache-side fetcher in +// helpers.go (fetchOpenAPISpec) and the user-facing curl tip surfaced +// by nextstep/resolver.go. The caller is responsible for the +// surrounding timeout (we honor ctx). Non-200 responses are reported +// as errors so the state assembler falls back to the on-disk cache +// rather than feeding a stale or 404-shaped body into +// ExtractInvokeExample. +func fetchLiveOpenAPI(ctx context.Context, port int) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("http://localhost:%d/invocations/docs/openapi.json", port), nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("openapi.json: %s", resp.Status) + } + return io.ReadAll(resp.Body) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index dfabb930e52..b7eba1dbddd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -6,8 +6,11 @@ package cmd import ( "bytes" "context" + "errors" "io" "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" @@ -640,3 +643,182 @@ func TestAppendPortEnvVars(t *testing.T) { } }) } + +// ---- waitForPortReady + fetchLiveOpenAPI (C8) ---- + +// listenLoopback opens a TCP listener on 127.0.0.1:0 so the tests +// pick up an OS-assigned free port. Returns the listener and its +// port; the caller is responsible for closing the listener. +func listenLoopback(t *testing.T) (net.Listener, int) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + return ln, ln.Addr().(*net.TCPAddr).Port +} + +func TestWaitForPortReady_ReturnsTrueWhenPortIsBound(t *testing.T) { + t.Parallel() + ln, port := listenLoopback(t) + t.Cleanup(func() { _ = ln.Close() }) + ok := waitForPortReady(t.Context(), port, 2*time.Second) + if !ok { + t.Fatalf("waitForPortReady returned false for bound port %d", port) + } +} + +func TestWaitForPortReady_ReturnsFalseWhenBudgetElapses(t *testing.T) { + t.Parallel() + // Grab a port and immediately release it so the dial reliably + // fails. There's still a small race where another process could + // re-bind it; using 127.0.0.1 instead of 0.0.0.0 keeps that + // surface tiny in CI. + ln, port := listenLoopback(t) + _ = ln.Close() + start := time.Now() + ok := waitForPortReady(t.Context(), port, 200*time.Millisecond) + elapsed := time.Since(start) + if ok { + t.Fatalf("waitForPortReady returned true for closed port %d", port) + } + if elapsed < 150*time.Millisecond { + t.Fatalf("waitForPortReady returned before exhausting budget (%s)", elapsed) + } +} + +func TestWaitForPortReady_ReturnsFalseOnContextCancellation(t *testing.T) { + t.Parallel() + ln, port := listenLoopback(t) + _ = ln.Close() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + ok := waitForPortReady(ctx, port, 2*time.Second) + if ok { + t.Fatalf("waitForPortReady returned true for cancelled ctx") + } +} + +func TestFetchLiveOpenAPI_Returns200Body(t *testing.T) { + t.Parallel() + body := []byte(`{"paths":{"/invocations":{"post":{}}}}`) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/invocations/docs/openapi.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + t.Cleanup(srv.Close) + + // Extract the port from the test server's URL so fetchLiveOpenAPI + // (which hard-codes localhost) targets the right listener. + u, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(srv.URL, "http://")) + if err != nil { + t.Fatalf("parse srv.URL: %v", err) + } + got, err := fetchLiveOpenAPI(t.Context(), u.Port) + if err != nil { + t.Fatalf("fetchLiveOpenAPI: %v", err) + } + if string(got) != string(body) { + t.Fatalf("body mismatch: got %q want %q", got, body) + } +} + +func TestFetchLiveOpenAPI_ReturnsErrorOnNon200(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + u, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(srv.URL, "http://")) + if err != nil { + t.Fatalf("parse srv.URL: %v", err) + } + _, err = fetchLiveOpenAPI(t.Context(), u.Port) + if err == nil { + t.Fatalf("expected non-nil error for 500 response") + } + if !strings.Contains(err.Error(), "openapi.json") { + t.Fatalf("error %q missing expected prefix", err) + } +} + +func TestFetchLiveOpenAPI_HonoursContextCancellation(t *testing.T) { + t.Parallel() + // Server that never responds — used to verify the supplied ctx + // (with a short deadline) reliably aborts the call. The + // time.Sleep mimics a slow-spec endpoint without coupling to a + // real network failure mode. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(500 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + u, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(srv.URL, "http://")) + if err != nil { + t.Fatalf("parse srv.URL: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + _, err = fetchLiveOpenAPI(ctx, u.Port) + if err == nil { + t.Fatalf("expected error from cancelled fetch") + } + if !errors.Is(err, context.DeadlineExceeded) && + !strings.Contains(err.Error(), "context deadline exceeded") { + t.Fatalf("error %q does not signal deadline exceeded", err) + } +} + +func TestEmitNextAfterBind_ReturnsSilentlyWhenPortNeverBinds(t *testing.T) { + t.Parallel() + // Grab and release a port so the dial reliably fails for the + // duration of the test. emitNextAfterBind must return without + // panicking even with a nil azdClient — the early-return paths + // (non-TTY stdout in `go test`, then port-bind timeout) execute + // before AssembleState is reached. + ln, port := listenLoopback(t) + _ = ln.Close() + done := make(chan struct{}) + go func() { + defer close(done) + // Bound the call so the default 5s budget doesn't block the + // test. The non-TTY gate fires first in `go test` (stdout is + // the test harness's pipe), so this primarily exercises the + // gate; with that gate removed, waitForPortReady's + // ctx-cancel path takes over. + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancel() + emitNextAfterBind(ctx, nil, "svc", port) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("emitNextAfterBind did not exit within 2s") + } +} + +func TestEmitNextAfterBind_ReturnsSilentlyOnContextCancellation(t *testing.T) { + t.Parallel() + // A live listener guarantees we'd otherwise progress past + // waitForPortReady; cancelling ctx immediately forces the + // goroutine to exit via the non-TTY gate or AssembleState + // returning quickly without printing. + ln, port := listenLoopback(t) + t.Cleanup(func() { _ = ln.Close() }) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + done := make(chan struct{}) + go func() { + defer close(done) + emitNextAfterBind(ctx, nil, "svc", port) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("emitNextAfterBind did not honor ctx cancel within 2s") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index ac7ddb575b3..5cf21274b5a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -13,6 +13,7 @@ import ( "text/tabwriter" "time" + "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/pkg/agents/agent_api" projectpkg "azureaiagent/internal/project" @@ -31,6 +32,9 @@ type ShowAction struct { flags *showFlags azdClient *azdext.AzdClient envName string + // serviceName is the azure.yaml service name used by the unknown-status + // re-check fallback that suggests `azd ai agent show `. + serviceName string // serviceKey is the uppercase/underscored form of the service name, // used to look up per-service env vars (e.g. AGENT_{KEY}_RESPONSES_ENDPOINT). serviceKey string @@ -107,6 +111,7 @@ configuration and the current azd environment. Optionally specify the service na flags: flags, azdClient: azdClient, envName: envName, + serviceName: info.ServiceName, serviceKey: toServiceKey(info.ServiceName), } @@ -128,6 +133,34 @@ type showResult struct { *agent_api.AgentVersionObject PlaygroundURL string `json:"playground_url,omitempty"` Endpoints map[string]string `json:"agent_endpoints,omitempty"` + NextStep *nextStepEnvelope `json:"next_step,omitempty"` +} + +// nextStepEnvelope is the JSON shape for context-aware guidance attached to +// `azd ai agent show --output json`. Mirrors []nextstep.Suggestion but +// omits the internal Priority/Trailing renderer hints — JSON consumers +// only need the command + description. +type nextStepEnvelope struct { + Suggestions []nextStepSuggestion `json:"suggestions"` +} + +type nextStepSuggestion struct { + Command string `json:"command"` + Description string `json:"description"` +} + +func toNextStepEnvelope(suggestions []nextstep.Suggestion) *nextStepEnvelope { + if len(suggestions) == 0 { + return nil + } + out := &nextStepEnvelope{Suggestions: make([]nextStepSuggestion, 0, len(suggestions))} + for _, s := range suggestions { + out.Suggestions = append(out.Suggestions, nextStepSuggestion{ + Command: s.Command, + Description: s.Description, + }) + } + return out } // Run executes the show command logic. @@ -152,20 +185,53 @@ func (a *ShowAction) Run(ctx context.Context) error { // Resolve deployed endpoint URLs from env vars (best-effort) result.Endpoints = a.resolveEndpointURLs(ctx) - return printShowResult(result, a.flags.output) + // Resolve context-aware next-step guidance only for statuses that need + // action. Active agents intentionally omit next_step so `show` stays a + // pure inspection command and JSON output remains close to the API shape. + var suggestions []nextstep.Suggestion + if shouldResolveShowNextStep(version.Status) { + suggestions = a.resolveNextStep(version.Status) + } + + return printShowResult(result, a.flags.output, suggestions) } -func printShowResult(result *showResult, output string) error { +func printShowResult(result *showResult, output string, suggestions []nextstep.Suggestion) error { switch output { case "", "table": - return printShowResultTable(result) + return printShowResultTable(result, suggestions) case "json": + result.NextStep = toNextStepEnvelope(suggestions) return printShowResultJSON(result) default: return fmt.Errorf("unsupported output format %q", output) } } +func shouldResolveShowNextStep(status string) bool { + switch nextstep.AgentVersionStatus(status) { + case nextstep.AgentVersionActive, nextstep.AgentVersionIdle: + return false + default: + return true + } +} + +// resolveNextStep asks the resolver for the post-show guidance block. +func (a *ShowAction) resolveNextStep(status string) []nextstep.Suggestion { + return resolveNextStepFromStatus(a.serviceName, status) +} + +// resolveNextStepFromStatus is the testable core of resolveNextStep. +func resolveNextStepFromStatus(serviceName, status string) []nextstep.Suggestion { + if !shouldResolveShowNextStep(status) { + return nil + } + + state := &nextstep.State{AgentStatus: status} + return nextstep.ResolveAfterShow(state, serviceName) +} + // resolvePlaygroundURL reads AZURE_AI_PROJECT_ID from the azd environment // and constructs the Foundry portal playground URL. Returns empty string on failure. func (a *ShowAction) resolvePlaygroundURL(ctx context.Context) string { @@ -245,7 +311,7 @@ func printShowResultJSON(result *showResult) error { return nil } -func printShowResultTable(result *showResult) error { +func printShowResultTable(result *showResult, suggestions []nextstep.Suggestion) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "FIELD\tVALUE") fmt.Fprintln(w, "-----\t-----") @@ -292,5 +358,17 @@ func printShowResultTable(result *showResult) error { fmt.Fprintf(w, "Endpoint (%s)\t%s\n", label, result.Endpoints[label]) } - return w.Flush() + if err := w.Flush(); err != nil { + return err + } + + // Next: guidance is human-only on the table path; the JSON envelope + // carries the same data for machines. Suppress on non-TTY (pipes, + // file redirection) so scripted consumers of the table output don't + // see surprise trailing lines. PrintNext owns the leading blank-line + // separator (see nextstep/format.go renderBlock), so we don't + // pre-emit one here. + _ = printNextIfTerminal(os.Stdout, suggestions) + + return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 846d52e7759..73302119212 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -9,6 +9,7 @@ import ( "os" "testing" + "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/pkg/agents/agent_api" "github.com/stretchr/testify/assert" @@ -76,7 +77,7 @@ func TestShowCommand_DefaultOutputFormat(t *testing.T) { func TestPrintShowResult_DefaultsToTable(t *testing.T) { output, err := captureStdout(t, func() error { - return printShowResult(sampleShowResult(), "") + return printShowResult(sampleShowResult(), "", nil) }) require.NoError(t, err) @@ -88,7 +89,7 @@ func TestPrintShowResult_DefaultsToTable(t *testing.T) { func TestPrintShowResult_JSONOptIn(t *testing.T) { output, err := captureStdout(t, func() error { - return printShowResult(sampleShowResult(), "json") + return printShowResult(sampleShowResult(), "json", nil) }) require.NoError(t, err) @@ -98,7 +99,7 @@ func TestPrintShowResult_JSONOptIn(t *testing.T) { func TestPrintShowResult_ExplicitTable(t *testing.T) { output, err := captureStdout(t, func() error { - return printShowResult(sampleShowResult(), "table") + return printShowResult(sampleShowResult(), "table", nil) }) require.NoError(t, err) @@ -109,7 +110,7 @@ func TestPrintShowResult_ExplicitTable(t *testing.T) { } func TestPrintShowResult_UnsupportedOutput(t *testing.T) { - err := printShowResult(sampleShowResult(), "yaml") + err := printShowResult(sampleShowResult(), "yaml", nil) assert.EqualError(t, err, `unsupported output format "yaml"`) } @@ -216,6 +217,55 @@ func TestPrintAgentVersionJSON_NoLinks(t *testing.T) { assert.False(t, hasPlayground, "playground_url should be omitted when empty") _, hasEndpoints := raw["agent_endpoints"] assert.False(t, hasEndpoints, "agent_endpoints should be omitted when nil") + _, hasNextStep := raw["next_step"] + assert.False(t, hasNextStep, "next_step should be omitted when nil") +} + +func TestShowResultJSON_NextStepEnvelope(t *testing.T) { + version := &agent_api.AgentVersionObject{ + Object: "agent.version", + ID: "ver-999", + Name: "my-agent", + Version: "1", + Status: "failed", + } + + result := &showResult{ + AgentVersionObject: version, + NextStep: toNextStepEnvelope([]nextstep.Suggestion{ + { + Command: "azd ai agent monitor --follow", + Description: "stream agent logs to investigate the failure", + Priority: 10, + }, + }), + } + + jsonBytes, err := json.MarshalIndent(result, "", " ") + require.NoError(t, err) + + var raw map[string]any + err = json.Unmarshal(jsonBytes, &raw) + require.NoError(t, err) + + nextStep, ok := raw["next_step"].(map[string]any) + require.True(t, ok, "next_step should be present and an object") + suggestions, ok := nextStep["suggestions"].([]any) + require.True(t, ok, "next_step.suggestions should be an array") + require.Len(t, suggestions, 1) + first := suggestions[0].(map[string]any) + assert.Equal(t, "azd ai agent monitor --follow", first["command"]) + assert.Equal(t, "stream agent logs to investigate the failure", first["description"]) + // Internal renderer hints (priority, trailing) must not leak into JSON. + _, hasPriority := first["priority"] + assert.False(t, hasPriority, "priority must not appear in JSON envelope") + _, hasTrailing := first["trailing"] + assert.False(t, hasTrailing, "trailing must not appear in JSON envelope") +} + +func TestToNextStepEnvelope_EmptyReturnsNil(t *testing.T) { + assert.Nil(t, toNextStepEnvelope(nil)) + assert.Nil(t, toNextStepEnvelope([]nextstep.Suggestion{})) } func TestPrintAgentVersionTable(t *testing.T) { @@ -252,7 +302,7 @@ func TestPrintAgentVersionTable(t *testing.T) { }, } - err := printShowResultTable(result) + err := printShowResultTable(result, nil) require.NoError(t, err) } @@ -265,7 +315,7 @@ func TestPrintAgentVersionTable_MinimalFields(t *testing.T) { } result := &showResult{AgentVersionObject: version} - err := printShowResultTable(result) + err := printShowResultTable(result, nil) require.NoError(t, err) } @@ -300,3 +350,51 @@ func captureStdout(t *testing.T, run func() error) (string, error) { return string(output), runErr } + +// TestResolveNextStepFromStatus_ActiveBranchNoSuggestion locks the active +// show contract: active/idle are already healthy, so show remains an +// inspection command and does not emit next_step guidance. +func TestResolveNextStepFromStatus_ActiveBranchNoSuggestion(t *testing.T) { + t.Parallel() + + out := resolveNextStepFromStatus("echo-svc", "active") + require.Empty(t, out) +} + +// TestResolveNextStepFromStatus_UnknownStatusFallsBackToServiceName locks +// the unknown-status branch: when the resolver can't classify the status, +// it suggests `azd ai agent show ` (not agentName), because +// show.go's lookup matches by service name. +func TestResolveNextStepFromStatus_UnknownStatusFallsBackToServiceName(t *testing.T) { + t.Parallel() + + out := resolveNextStepFromStatus("echo-svc", "Transitioning") + require.Len(t, out, 1) + assert.Equal(t, "azd ai agent show echo-svc", out[0].Command) +} + +// TestResolveNextStepFromStatus_NonActiveBranches sanity-checks the +// remaining status branches don't depend on either service or agent name. +func TestResolveNextStepFromStatus_NonActiveBranches(t *testing.T) { + t.Parallel() + + tests := []struct { + status string + want string + }{ + {"creating", "azd ai agent monitor --type system --follow"}, + {"failed", "azd ai agent monitor --follow"}, + {"", "azd ai agent monitor --follow"}, + {"deleting", "azd deploy"}, + {"deleted", "azd deploy"}, + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + t.Parallel() + out := resolveNextStepFromStatus("echo-svc", tt.status) + require.Len(t, out, 1) + assert.Equal(t, tt.want, out[0].Command) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parameters.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parameters.go index d28822660a0..f9af5474ace 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parameters.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parameters.go @@ -22,28 +22,89 @@ func ProcessManifestParameters( manifest *AgentManifest, azdClient *azdext.AzdClient, noPrompt bool) (*AgentManifest, error) { - // If no parameters are defined, return the manifest as-is + result := manifest + if len(manifest.Parameters.Properties) == 0 { + // No declared parameters — nothing to prompt for. The warning at + // the end still runs in case the manifest contains literal + // `{{NAME}}` tokens that the author forgot to declare under + // `parameters:` (typo / drift); we want to surface those. log.Print("The manifest does not contain parameters that need to be configured.") - return manifest, nil + } else { + fmt.Println("The manifest contains parameters that need to be configured:") + fmt.Println() + + // Collect parameter values from user + paramValues, err := promptForYamlParameterValues(ctx, manifest.Parameters, azdClient, noPrompt) + if err != nil { + return nil, fmt.Errorf("failed to collect parameter values: %w", err) + } + + // Inject parameter values into the manifest + processedManifest, err := InjectParameterValuesIntoManifest(manifest, paramValues) + if err != nil { + return nil, fmt.Errorf("failed to inject parameter values into manifest: %w", err) + } + result = processedManifest } - fmt.Println("The manifest contains parameters that need to be configured:") - fmt.Println() + // Surface a warning for any placeholders that survive substitution. + // + // This is the right call site for the warning because all declared + // parameters have just been prompted-for and substituted, and + // model-resource placeholders were already substituted earlier by + // `ProcessModels` (init.go calls configureModelChoice before + // ProcessManifestParameters). Any `{{NAME}}` still present here is + // either a parameter the manifest author forgot to declare under + // `parameters:` (typo / drift) or a literal `{{...}}` token the + // author intends to ship as-is. Either case warrants the warning, + // and the `Edit agent.yaml` advice is actionable from this point + // onward (the caller will write agent.yaml to disk moments later). + // + // Earlier call sites of `InjectParameterValuesIntoManifest` (notably + // `ProcessModels` in init_models.go which substitutes only model + // deployment names) must NOT warn — at those points, user-configurable + // placeholders are expected to still be present and are about to be + // prompted-for here in `ProcessManifestParameters`. + if err := warnUnresolvedManifestPlaceholders(result); err != nil { + // Non-fatal: the manifest was either passed in by the caller + // or just successfully re-loaded by InjectParameterValuesIntoManifest, + // so a marshal failure here would be surprising. Log it and continue. + log.Printf("failed to scan manifest for unresolved placeholders: %v", err) + } + + return result, nil +} - // Collect parameter values from user - paramValues, err := promptForYamlParameterValues(ctx, manifest.Parameters, azdClient, noPrompt) +// warnUnresolvedManifestPlaceholders re-marshals the template that will be +// written to agent.yaml and prints a stdout warning naming any surviving +// `{{NAME}}` placeholders. The nextstep guidance system uses the same +// `PlaceholderPattern` so the warning names and the post-init `Next:` block +// stay aligned (a placeholder reported in the warning must show up in the +// Next: block, and vice versa). +// +// Scans only `manifest.Template` because that's what `writeAgentDefinitionFile` +// marshals to agent.yaml; placeholders in other manifest sections (parameters, +// resources) never reach the on-disk file, so naming them in an +// "Edit agent.yaml" warning would mislead the user. +func warnUnresolvedManifestPlaceholders(manifest *AgentManifest) error { + templateBytes, err := yaml.Marshal(manifest.Template) if err != nil { - return nil, fmt.Errorf("failed to collect parameter values: %w", err) + return fmt.Errorf("failed to marshal template for placeholder scan: %w", err) } - // Inject parameter values into the manifest - processedManifest, err := InjectParameterValuesIntoManifest(manifest, paramValues) - if err != nil { - return nil, fmt.Errorf("failed to inject parameter values into manifest: %w", err) + remaining := ExtractUnresolvedPlaceholders(string(templateBytes)) + if len(remaining) == 0 { + return nil } - return processedManifest, nil + fmt.Printf( + "Warning: agent.yaml has %d unresolved placeholder(s): %s. "+ + "Edit agent.yaml and replace each `{{NAME}}` with the actual value before deploying.\n", + len(remaining), + strings.Join(remaining, ", "), + ) + return nil } // promptForYamlParameterValues prompts the user for values for each YAML parameter @@ -231,9 +292,22 @@ func promptForTextValue( return resp.Value, nil } -// injectParameterValues replaces parameter placeholders in the template with actual values +// injectParameterValues replaces parameter placeholders in the template with +// actual values. Both compact (`{{NAME}}`) and spaced (`{{ NAME }}`) forms +// are substituted. +// +// This helper is intentionally silent about any placeholders that remain +// unresolved after substitution. It is called from two paths during init — +// `ProcessModels` (which substitutes only model-deployment-name parameters +// and intentionally leaves user-configurable placeholders for later) and +// `ProcessManifestParameters` (which substitutes user parameters and is +// the final substitution step before the manifest is written to disk). +// Emitting a "you have unresolved placeholders" warning here would +// false-positive from the `ProcessModels` path with names that are about +// to be prompted-for in `ProcessManifestParameters`. The warning therefore +// lives in `warnUnresolvedManifestPlaceholders`, which is called only from +// `ProcessManifestParameters` after both substitution steps complete. func injectParameterValues(template string, paramValues ParameterValues) ([]byte, error) { - // Replace each parameter placeholder with its value for paramName, paramValue := range paramValues { placeholder := fmt.Sprintf("{{%s}}", paramName) valueStr := fmt.Sprintf("%v", paramValue) @@ -243,10 +317,5 @@ func injectParameterValues(template string, paramValues ParameterValues) ([]byte template = strings.ReplaceAll(template, placeholder, valueStr) } - // Check for any remaining unreplaced placeholders - if strings.Contains(template, "{{") && strings.Contains(template, "}}") { - fmt.Println("Warning: Template contains unresolved placeholders.") - } - return []byte(template), nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/placeholders.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/placeholders.go new file mode 100644 index 00000000000..9af00b597ea --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/placeholders.go @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "regexp" + "slices" +) + +// PlaceholderPattern captures {{NAME}} Mustache-style placeholders that +// injectParameterValues is supposed to replace before producing the +// final agent.yaml. Surviving placeholders are deploy-time landmines: +// the value lands in the container literally as `{{NAME}}`, breaking +// the agent. +// +// The capture group accepts any run of non-brace characters (allowing +// internal whitespace as long as the name starts with a non-whitespace, +// non-brace char) because injectParameterValues substitutes the raw +// manifest parameter key without validating its shape +// (`strings.ReplaceAll` of `{{}}` and `{{ }}`), +// and the YAML decoder assigns the raw key to Property.Name without +// validation either. A legitimate manifest parameter named +// `toolbox-endpoint` (hyphen), `my.param` (dot), or `"my key"` (quoted +// YAML key with whitespace) would otherwise slip past detection. +// Allows optional surrounding whitespace inside the braces — matches +// both `{{NAME}}` and `{{ NAME }}` (the two forms +// injectParameterValues knows how to substitute) plus more liberal +// spacing for forgiving detection. +// +// Shared between this package's post-substitution warning and the +// nextstep `Next:` guidance so the two stay in lockstep. +var PlaceholderPattern = regexp.MustCompile(`\{\{\s*([^\s{}][^{}]*?)\s*\}\}`) + +// ExtractUnresolvedPlaceholders returns the deduplicated, sorted list +// of placeholder NAMES (i.e. the inside of `{{...}}`) that remain in +// template. An empty slice means the template is fully substituted. +// +// Used by both injectParameterValues (to surface a specific warning +// naming the unresolved placeholders) and by nextstep's fix-up +// generator (to surface the same names in the `Next:` block as a +// concrete "edit agent.yaml" hint). The two call sites must agree +// on what counts as a placeholder, hence the shared helper. +func ExtractUnresolvedPlaceholders(template string) []string { + matches := PlaceholderPattern.FindAllStringSubmatch(template, -1) + if len(matches) == 0 { + return nil + } + seen := make(map[string]struct{}, len(matches)) + out := make([]string, 0, len(matches)) + for _, m := range matches { + if len(m) < 2 { + continue + } + name := m[1] + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + slices.Sort(out) + return out +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/placeholders_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/placeholders_test.go new file mode 100644 index 00000000000..bcb7b93d0a0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/placeholders_test.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractUnresolvedPlaceholders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty template returns nil", + input: "", + expected: nil, + }, + { + name: "fully substituted template returns nil", + input: "key: real-value\nother: another-real-value\n", + expected: nil, + }, + { + name: "single placeholder", + input: "endpoint: {{TOOLBOX_ENDPOINT}}\n", + expected: []string{"TOOLBOX_ENDPOINT"}, + }, + { + name: "multiple distinct placeholders sorted alphabetically", + input: "a: {{ZEBRA}}\nb: {{APPLE}}\nc: {{MANGO}}\n", + expected: []string{"APPLE", "MANGO", "ZEBRA"}, + }, + { + name: "duplicate placeholders deduplicated", + input: "a: {{NAME}}-{{NAME}}\nb: {{NAME}}\n", + expected: []string{"NAME"}, + }, + { + name: "hyphenated paramName captured", + input: "endpoint: {{toolbox-endpoint}}\n", + expected: []string{"toolbox-endpoint"}, + }, + { + name: "dotted paramName captured", + input: "component: {{my.component.id}}\n", + expected: []string{"my.component.id"}, + }, + { + name: "whitespace inside braces tolerated and stripped", + input: "a: {{ FOO }}\nb: {{ BAR }}\n", + expected: []string{"BAR", "FOO"}, + }, + { + name: "empty braces do not match", + input: "a: {{}}\nb: real-value\n", + expected: nil, + }, + { + name: "whitespace-only braces do not match", + input: "a: {{ }}\nb: real-value\n", + expected: nil, + }, + { + name: "spaced and unspaced forms of the same name deduplicated", + input: "a: {{NAME}}\nb: {{ NAME }}\n", + expected: []string{"NAME"}, + }, + { + name: "mixed real values and placeholders", + input: "a: actual\nb: {{MISSING_ONE}}\nc: actual\nd: {{MISSING_TWO}}\n", + expected: []string{"MISSING_ONE", "MISSING_TWO"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ExtractUnresolvedPlaceholders(tt.input) + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/envkey/envkey.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/envkey/envkey.go new file mode 100644 index 00000000000..8604bbd8a0d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/envkey/envkey.go @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package envkey produces the canonical environment-variable keys that +// azd's hosted-agent toolbox flow reads and writes. +// +// Both the provisioning side (init.go injecting `TOOLBOX__MCP_ENDPOINT` +// references into the agent manifest, listen.go writing each value at +// runtime) and the diagnostic side (doctor `local.toolboxes` checking +// that those vars are set) must agree byte-for-byte on the key name — +// any divergence produces a false-negative diagnostic where the value +// is present under a different key than the check looks for. This +// package is the single source of truth for that key. +package envkey + +import ( + "fmt" + "regexp" + "strings" +) + +// nonAlphanumRe matches one or more characters that are not an +// upper-case ASCII letter or digit. Runs of such characters collapse +// to a single underscore — e.g. "my--tool" -> "MY_TOOL", "my(tool)" -> +// "MY_TOOL_", "my+tool" -> "MY_TOOL". +var nonAlphanumRe = regexp.MustCompile(`[^A-Z0-9]+`) + +// ToolboxMCPEndpoint returns the canonical env-var key for a hosted +// toolbox's MCP endpoint URL. The convention is: +// +// TOOLBOX__MCP_ENDPOINT +// +// where sanitize collapses any run of non-`[A-Z0-9]` characters to a +// single underscore. Examples: +// +// "web-search-tools" -> "TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT" +// "my tools" -> "TOOLBOX_MY_TOOLS_MCP_ENDPOINT" +// "my--tool" -> "TOOLBOX_MY_TOOL_MCP_ENDPOINT" +// "my:tool" -> "TOOLBOX_MY_TOOL_MCP_ENDPOINT" +func ToolboxMCPEndpoint(toolboxName string) string { + sanitized := nonAlphanumRe.ReplaceAllString(strings.ToUpper(toolboxName), "_") + return fmt.Sprintf("TOOLBOX_%s_MCP_ENDPOINT", sanitized) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/envkey/envkey_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/envkey/envkey_test.go new file mode 100644 index 00000000000..03d0a52463e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/envkey/envkey_test.go @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package envkey + +import "testing" + +func TestToolboxMCPEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + {"simple-hyphen", "my-tools", "TOOLBOX_MY_TOOLS_MCP_ENDPOINT"}, + {"single-space", "my tools", "TOOLBOX_MY_TOOLS_MCP_ENDPOINT"}, + {"mixed-segments", "agent-tools v2", "TOOLBOX_AGENT_TOOLS_V2_MCP_ENDPOINT"}, + {"already-upper", "TOOLS", "TOOLBOX_TOOLS_MCP_ENDPOINT"}, + {"dot-separator", "my.toolbox.v2", "TOOLBOX_MY_TOOLBOX_V2_MCP_ENDPOINT"}, + // Run-collapsing - without it doctor would search for + // TOOLBOX_MY__TOOL_MCP_ENDPOINT and miss the real value. + {"double-hyphen-run", "my--tool", "TOOLBOX_MY_TOOL_MCP_ENDPOINT"}, + // Symbol classes that bypassed the previous rune-by-rune + // normalizer (it only mapped `-`, `.`, ` ` to `_`). + {"plus", "my+tool", "TOOLBOX_MY_TOOL_MCP_ENDPOINT"}, + {"colon", "my:tool", "TOOLBOX_MY_TOOL_MCP_ENDPOINT"}, + {"slash", "my/tool", "TOOLBOX_MY_TOOL_MCP_ENDPOINT"}, + {"tab", "my\ttool", "TOOLBOX_MY_TOOL_MCP_ENDPOINT"}, + // Trailing non-alphanum produces a trailing underscore inside + // the sanitized segment, which is consistent with how listen.go + // has always written the value. + {"parens", "my(tool)", "TOOLBOX_MY_TOOL__MCP_ENDPOINT"}, + {"mixed-case-symbols", "Web-Search:V2", "TOOLBOX_WEB_SEARCH_V2_MCP_ENDPOINT"}, + {"empty", "", "TOOLBOX__MCP_ENDPOINT"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToolboxMCPEndpoint(tt.input) + if got != tt.expected { + t.Errorf("ToolboxMCPEndpoint(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/paths/paths.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/paths/paths.go new file mode 100644 index 00000000000..2d2bed968af --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/paths/paths.go @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package paths validates and resolves paths under an azd project root. +package paths + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "slices" + "strings" + "unicode" +) + +// Join resolves relativePath and elems under projectRoot. +func Join(projectRoot, relativePath string, elems ...string) (string, error) { + return join(projectRoot, relativePath, false, elems...) +} + +// JoinAllowRoot resolves relativePath and elems under projectRoot, allowing an +// empty or "." relativePath to mean the project root itself. +func JoinAllowRoot(projectRoot, relativePath string, elems ...string) (string, error) { + return join(projectRoot, relativePath, true, elems...) +} + +func join(projectRoot, relativePath string, allowRoot bool, elems ...string) (string, error) { + if strings.TrimSpace(projectRoot) == "" { + return "", fmt.Errorf("project root is empty") + } + + rootAbs, err := filepath.Abs(projectRoot) + if err != nil { + return "", fmt.Errorf("resolve project root: %w", err) + } + rootAbs = filepath.Clean(rootAbs) + + cleanRel, err := cleanRelativePath(relativePath, allowRoot) + if err != nil { + return "", err + } + + parts := []string{rootAbs} + if cleanRel != "." { + parts = append(parts, filepath.FromSlash(cleanRel)) + } + parts = append(parts, elems...) + + resolved, err := filepath.Abs(filepath.Join(parts...)) + if err != nil { + return "", fmt.Errorf("resolve project-relative path: %w", err) + } + resolved = filepath.Clean(resolved) + + if !isSubpath(resolved, rootAbs) { + return "", fmt.Errorf("path %q escapes project root", relativePath) + } + if err := validateResolvedSubpath(resolved, rootAbs, relativePath); err != nil { + return "", err + } + + return resolved, nil +} + +func cleanRelativePath(relativePath string, allowRoot bool) (string, error) { + if relativePath == "" { + if allowRoot { + return ".", nil + } + return "", fmt.Errorf("relative path is empty") + } + if strings.TrimSpace(relativePath) == "" { + return "", fmt.Errorf("relative path is empty") + } + + normalized := strings.ReplaceAll(relativePath, "\\", "/") + if strings.HasPrefix(normalized, "/") || hasWindowsVolume(normalized) { + return "", fmt.Errorf("relative path %q must not be absolute", relativePath) + } + + if slices.Contains(strings.Split(normalized, "/"), "..") { + return "", fmt.Errorf("relative path %q must not contain '..'", relativePath) + } + + cleaned := path.Clean(normalized) + if cleaned == "." && !allowRoot { + return "", fmt.Errorf("relative path %q resolves to project root", relativePath) + } + if strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("relative path %q escapes project root", relativePath) + } + + return cleaned, nil +} + +func hasWindowsVolume(p string) bool { + if len(p) >= 2 && p[1] == ':' && unicode.IsLetter(rune(p[0])) { + return true + } + return strings.HasPrefix(p, "//") +} + +func isSubpath(child, parent string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func validateResolvedSubpath(targetPath, rootPath, relativePath string) error { + rootReal, err := filepath.EvalSymlinks(rootPath) + if err != nil { + return fmt.Errorf("resolve project root symlinks: %w", err) + } + rootReal = filepath.Clean(rootReal) + + existing, err := deepestExistingPath(targetPath) + if err != nil { + return err + } + + existingReal, err := filepath.EvalSymlinks(existing) + if err != nil { + return fmt.Errorf("resolve project-relative path symlinks: %w", err) + } + existingReal = filepath.Clean(existingReal) + + if !isSubpath(existingReal, rootReal) { + return fmt.Errorf("path %q escapes project root", relativePath) + } + + return nil +} + +func deepestExistingPath(targetPath string) (string, error) { + current := filepath.Clean(targetPath) + for { + if _, err := os.Lstat(current); err == nil { + return current, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("inspect project-relative path: %w", err) + } + + next := filepath.Dir(current) + if next == current { + return "", fmt.Errorf("project-relative path does not have an existing ancestor") + } + current = next + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/paths/paths_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/paths/paths_test.go new file mode 100644 index 00000000000..54fc73c1350 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/paths/paths_test.go @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package paths + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestJoin(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + tests := []struct { + name string + rel string + want string + }{ + {"nested path", "src/agent", filepath.Join(root, "src", "agent", "agent.yaml")}, + {"dot segment normalizes", "./src/agent", filepath.Join(root, "src", "agent", "agent.yaml")}, + {"windows separator normalizes", `src\agent`, filepath.Join(root, "src", "agent", "agent.yaml")}, + {"spaces are preserved", " src ", filepath.Join(root, " src ", "agent.yaml")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := Join(root, tt.rel, "agent.yaml") + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestJoinRejectsUnsafePaths(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + tests := []struct { + name string + rel string + }{ + {"empty", ""}, + {"whitespace", " "}, + {"dot root", "."}, + {"parent traversal", "../outside"}, + {"nested traversal", "src/../outside"}, + {"windows traversal", `src\..\outside`}, + {"absolute", filepath.Join(root, "outside")}, + {"windows drive", `C:\outside`}, + {"unc", `\\server\share`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := Join(root, tt.rel, "agent.yaml") + + require.Error(t, err) + }) + } +} + +func TestJoinAllowRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + for _, rel := range []string{"", "."} { + got, err := JoinAllowRoot(root, rel, "agent.yaml") + + require.NoError(t, err) + require.Equal(t, filepath.Join(root, "agent.yaml"), got) + } +} + +func TestJoinAllowRootRejectsSymlinkEscapingRoot(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + root := filepath.Join(parent, "project") + outside := filepath.Join(parent, "outside") + require.NoError(t, os.MkdirAll(root, 0o750)) + require.NoError(t, os.MkdirAll(outside, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "agent.yaml"), []byte("outside"), 0o600)) + + createSymlinkOrSkip(t, outside, filepath.Join(root, "svc")) + + _, err := JoinAllowRoot(root, "svc", "agent.yaml") + + require.Error(t, err) +} + +func TestJoinAllowRootAllowsSymlinkWithinRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + target := filepath.Join(root, "target") + require.NoError(t, os.MkdirAll(target, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(target, "agent.yaml"), []byte("inside"), 0o600)) + + createSymlinkOrSkip(t, target, filepath.Join(root, "svc")) + + got, err := JoinAllowRoot(root, "svc", "agent.yaml") + + require.NoError(t, err) + require.Equal(t, filepath.Join(root, "svc", "agent.yaml"), got) +} + +func TestJoinAllowRootAllowsMissingLeafUnderRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "svc"), 0o750)) + + got, err := JoinAllowRoot(root, "svc", "README.md") + + require.NoError(t, err) + require.Equal(t, filepath.Join(root, "svc", "README.md"), got) +} + +func createSymlinkOrSkip(t *testing.T, oldname, newname string) { + t.Helper() + + if err := os.Symlink(oldname, newname); err != nil { + if errors.Is(err, os.ErrPermission) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("create symlink: %v", err) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_query.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_query.go new file mode 100644 index 00000000000..aef493f8c21 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_query.go @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// AgentPrincipal identifies one deployed agent's managed-identity +// principal — the entity whose role assignments the doctor's +// `remote.agent-identity-roles` check enumerates. +// +// AgentName / AgentVersion are the deployment-side coordinates surfaced +// to the user in Details and Messages. PrincipalID is the AAD object +// ID returned by Foundry's `GetAgentVersion` under +// `instance_identity.principal_id`; QueryAgentIdentityRoles is purely +// read-side and does not create or look up principals itself. +type AgentPrincipal struct { + AgentName string + AgentVersion string + PrincipalID string +} + +// AgentScopeRoles is the per-scope, per-agent listing the +// `remote.agent-identity-roles` check renders. Empty Roles is a +// meaningful state ("no role assignment on this scope") — callers must +// distinguish nil Roles (probe failed for this scope) from an empty +// non-nil slice (the probe succeeded and the principal has no roles +// there). +type AgentScopeRoles struct { + // Scope is the friendly label ("project", "account", + // "resource-group") used in user-facing output. The raw ARM scope + // ARN is omitted — the caller already knows it from + // `AgentIdentityRolesResult.Scopes` and surfacing it again per-row + // hurts redaction more than it helps the user. + Scope string + // Roles is the list of human-readable role names (e.g., + // "Cognitive Services User"). When the listing succeeded but the + // principal had no assignments at this scope, Roles is non-nil + // and empty. nil indicates the per-scope probe failed and the + // caller should treat the scope as "unknown". + Roles []string + // Err captures the per-scope probe error when the listing failed. + // nil for successful empty-list responses. + Err error +} + +// AgentIdentityRolesEntry is the per-agent listing folded across the +// three probed scopes. AgentName / AgentVersion / PrincipalID echo the +// input AgentPrincipal so consumers do not need to thread the input +// alongside the output. ProjectScope / AccountScope / RGScope each +// carry the per-scope outcome. +type AgentIdentityRolesEntry struct { + AgentName string + AgentVersion string + PrincipalID string + ProjectScope AgentScopeRoles + AccountScope AgentScopeRoles + RGScope AgentScopeRoles +} + +// AgentIdentityRolesResult is the side-effect-free outcome of +// QueryAgentIdentityRoles. Entries preserves the input order (sorted +// by AgentName upstream so output is deterministic). Scopes captures +// the raw ARN of each scope the listings ran against — diagnostics +// surface the friendly label (`ProjectScope.Scope`) but JSON +// consumers may need the raw ARN; redacting consumers can replace +// these with `` after assembly. +type AgentIdentityRolesResult struct { + Entries []AgentIdentityRolesEntry + Scopes AgentIdentityScopes +} + +// AgentIdentityScopes is the resolved scope ARN trio for an agent's +// identity-role listing. Account is the parent AI account ARN +// (`/subscriptions/.../accounts/`); Project is the agent's +// hosting project ARN (`/subscriptions/.../accounts/.../projects/

`); +// ResourceGroup is `/subscriptions/.../resourceGroups/`. +type AgentIdentityScopes struct { + Account string + Project string + ResourceGroup string +} + +// QueryAgentIdentityRoles enumerates each principal's role assignments +// at the agent's three reachable scopes (project, account, resource +// group) and returns a structured listing for the doctor's +// `remote.agent-identity-roles` check. +// +// The function follows the same credential-acquisition pattern as +// EnsureAgentIdentityRBAC: parse the project ARM ID, resolve the +// user-access tenant via the azd extension, and create an +// AzureDeveloperCLICredential pinned to that tenant. Per-principal +// probing fans out across scopes; a failure on one scope does not +// short-circuit the others so the user always sees a complete picture +// of where the listing succeeded. +// +// Callers MUST validate the projectResourceID first (e.g., via +// ValidateProjectResourceID) — this function returns a hard error if +// parsing fails so the doctor can surface "AZURE_AI_PROJECT_ID is +// malformed" without rendering an empty listing. +// +// An empty `principals` slice returns a result with empty Entries and +// no error (the caller's check fires a Skip in that case — there is +// nothing to enumerate but the listing path itself is healthy). +func QueryAgentIdentityRoles( + ctx context.Context, + azdClient *azdext.AzdClient, + projectResourceID string, + principals []AgentPrincipal, +) (*AgentIdentityRolesResult, error) { + info, err := parseAgentIdentityInfo(projectResourceID) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidProjectResourceID, err) + } + + scopes := AgentIdentityScopes{ + Account: info.AccountScope, + Project: info.ProjectScope, + ResourceGroup: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", info.SubscriptionID, info.ResourceGroup), + } + + if len(principals) == 0 { + return &AgentIdentityRolesResult{Scopes: scopes}, nil + } + + tenantResponse, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: info.SubscriptionID, + }) + if err != nil { + return nil, fmt.Errorf("failed to look up tenant for subscription %s: %w", info.SubscriptionID, err) + } + + cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: tenantResponse.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credential: %w", err) + } + + return queryAgentIdentityRolesWithLister(ctx, scopes, principals, func(ctx context.Context, scope, principalID string) ([]string, error) { + return listRoleNamesAtScope(ctx, cred, info.SubscriptionID, scope, principalID) + }) +} + +// queryAgentIdentityRolesWithLister is the test-seam-friendly core of +// QueryAgentIdentityRoles. It accepts an injected `lister` so unit +// tests can drive every per-scope branch (success / empty / +// transport-error) without standing up an ARM fake. +// +// Production callers use QueryAgentIdentityRoles, which builds the +// real lister from an AzureDeveloperCLICredential. +func queryAgentIdentityRolesWithLister( + ctx context.Context, + scopes AgentIdentityScopes, + principals []AgentPrincipal, + lister func(ctx context.Context, scope, principalID string) ([]string, error), +) (*AgentIdentityRolesResult, error) { + out := &AgentIdentityRolesResult{ + Entries: make([]AgentIdentityRolesEntry, len(principals)), + Scopes: scopes, + } + + var wg sync.WaitGroup + for i, p := range principals { + wg.Go(func() { + entry := AgentIdentityRolesEntry{ + AgentName: p.AgentName, + AgentVersion: p.AgentVersion, + PrincipalID: p.PrincipalID, + } + if p.PrincipalID == "" { + err := errors.New("principal ID unavailable") + entry.ProjectScope = AgentScopeRoles{Scope: "project", Err: err} + entry.AccountScope = AgentScopeRoles{Scope: "account", Err: err} + entry.RGScope = AgentScopeRoles{Scope: "resource-group", Err: err} + out.Entries[i] = entry + return + } + entry.ProjectScope = probeOneScope(ctx, "project", scopes.Project, p.PrincipalID, lister) + entry.AccountScope = probeOneScope(ctx, "account", scopes.Account, p.PrincipalID, lister) + entry.RGScope = probeOneScope(ctx, "resource-group", scopes.ResourceGroup, p.PrincipalID, lister) + out.Entries[i] = entry + }) + } + wg.Wait() + return out, nil +} + +// probeOneScope wraps a per-scope listing call so the caller's +// AgentIdentityRolesEntry assembly stays a flat three-line composition. +// Returns a non-nil Roles (possibly empty) on success and nil Roles +// with a populated Err on failure. +func probeOneScope( + ctx context.Context, + label, scope, principalID string, + lister func(ctx context.Context, scope, principalID string) ([]string, error), +) AgentScopeRoles { + roles, err := lister(ctx, scope, principalID) + if err != nil { + return AgentScopeRoles{Scope: label, Err: err} + } + if roles == nil { + roles = []string{} + } + return AgentScopeRoles{Scope: label, Roles: roles} +} + +// listRoleNamesAtScope returns the role names assigned to principalID +// at the supplied ARM scope. The function uses ARM's server-side +// `assignedTo()` filter to avoid pulling every assignment in the +// scope, then resolves each role-definition ID into a human-readable +// name (with caching across calls within a single QueryAgentIdentityRoles +// invocation via the wg.Go workers' captured closure — caching is not +// strictly necessary at 3 calls × N agents but trims a few ARM round +// trips when a role is reused). +// +// The function is intentionally tolerant of partial failures: any +// per-assignment resolution error becomes an empty name; the listing +// still returns the rest of the assignments. The doctor surfaces the +// listing as INFO so missing role names are a soft degradation, not +// a hard failure. +func listRoleNamesAtScope( + ctx context.Context, + cred *azidentity.AzureDeveloperCLICredential, + subscriptionID, scope, principalID string, +) ([]string, error) { + if scope == "" { + return nil, fmt.Errorf("empty scope") + } + if principalID == "" { + return nil, fmt.Errorf("empty principal ID") + } + if subscriptionID == "" { + return nil, fmt.Errorf("empty subscription ID") + } + + client, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create role-assignments client: %w", err) + } + defClient, err := armauthorization.NewRoleDefinitionsClient(cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create role-definitions client: %w", err) + } + + filter := fmt.Sprintf("assignedTo('%s')", principalID) + pager := client.NewListForScopePager(scope, &armauthorization.RoleAssignmentsClientListForScopeOptions{ + Filter: &filter, + }) + + roleDefIDs := make([]string, 0, 4) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list role assignments at scope %s: %w", scope, err) + } + for _, ra := range page.Value { + if ra.Properties == nil || ra.Properties.RoleDefinitionID == nil { + continue + } + roleDefIDs = append(roleDefIDs, *ra.Properties.RoleDefinitionID) + } + } + + cache := make(map[string]string, len(roleDefIDs)) + names := make([]string, 0, len(roleDefIDs)) + for _, defID := range roleDefIDs { + if cached, ok := cache[defID]; ok { + if cached != "" { + names = append(names, cached) + } + continue + } + name := resolveRoleName(ctx, defClient, defID) + cache[defID] = name + if name != "" { + names = append(names, name) + } + } + return names, nil +} + +// resolveRoleName fetches the human-readable role-definition name for +// a `/.../roleDefinitions/` ARM ID. The scope passed to +// `RoleDefinitions.Get` is the resource scope of the assignment, but +// since the role definition is global to its assignable scope chain +// (typically subscription-level for built-in roles), we use the +// subscription extracted from the role-definition ARM ID itself as +// the listing scope. +// +// Returns "" on any failure — callers omit empty names from the +// rendered listing. This matches the design's principle that the +// check's INFO classification is a soft surface; a missing role name +// should not turn the whole check red. +func resolveRoleName( + ctx context.Context, + defClient *armauthorization.RoleDefinitionsClient, + roleDefID string, +) string { + // Role-definition ARM IDs are of the form: + // /subscriptions//providers/Microsoft.Authorization/roleDefinitions/ + // For built-in roles (which is the common case for agent MIs) + // the listing scope is the subscription. Strip the trailing + // `/providers/...` to derive the scope; on a parse miss, treat + // the ARM ID itself as both scope and name input. + idx := strings.Index(roleDefID, "/providers/") + scope := roleDefID + if idx > 0 { + scope = roleDefID[:idx] + } + name := roleDefID[strings.LastIndex(roleDefID, "/")+1:] + if name == "" { + return "" + } + + resp, err := defClient.Get(ctx, scope, name, nil) + if err != nil { + return "" + } + if resp.Properties == nil || resp.Properties.RoleName == nil { + return "" + } + return *resp.Properties.RoleName +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/developer_rbac_query.go b/cli/azd/extensions/azure.ai.agents/internal/project/developer_rbac_query.go new file mode 100644 index 00000000000..00f02832fc5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/developer_rbac_query.go @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/graphsdk" +) + +// ErrInvalidProjectResourceID is the sentinel error returned by +// ValidateProjectResourceID (and wrapped by QueryDeveloperRBAC) when +// the supplied resource ID does not match the Foundry project ARM +// resource ID shape. Diagnostic consumers use +// `errors.Is(err, ErrInvalidProjectResourceID)` to distinguish +// user-fixable configuration errors (handled by an `azd env set` +// suggestion) from transient probe errors (handled by retry). +var ErrInvalidProjectResourceID = errors.New("invalid project resource ID") + +// ErrSPNDelegatedAuthRequired is the sentinel error returned by +// QueryDeveloperRBAC when the underlying Graph `/me` call rejects +// the access token because it was issued for a service principal +// (Graph `/me` requires user-delegated auth). Diagnostic consumers +// use `errors.Is(err, ErrSPNDelegatedAuthRequired)` to surface a +// SPN-aware Skip message instead of a generic transient-failure +// retry hint. +var ErrSPNDelegatedAuthRequired = errors.New("service-principal sign-in detected; Graph /me requires user-delegated auth") + +// ValidateProjectResourceID returns a non-nil error wrapping +// ErrInvalidProjectResourceID if the supplied string is not a +// Foundry project ARM resource ID +// (`/subscriptions/.../accounts/.../projects/...`). The inner +// parseAgentIdentityInfo error is preserved via `%w` for callers +// that want to surface the raw failure when redaction is off. +func ValidateProjectResourceID(projectResourceID string) error { + if _, err := parseAgentIdentityInfo(projectResourceID); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidProjectResourceID, err) + } + return nil +} + +// DeveloperRBACResult is the side-effect-free outcome of +// QueryDeveloperRBAC: a structured snapshot of the developer +// principal's RBAC posture on a Foundry project, suitable for +// diagnostics (`azd ai agent doctor`'s `remote.rbac` check). +// +// All fields are best-effort — a populated PrincipalID with an +// empty PrincipalDisplay (for example) is possible if Graph +// returns the OID but not the display name. Callers should treat +// the absence of a field as "unknown", not as a contradiction. +type DeveloperRBACResult struct { + // PrincipalID is the developer's Azure AD object ID (oid) as + // reported by Microsoft Graph's `/me` endpoint. + PrincipalID string + + // PrincipalDisplay is the developer's display name (or empty + // string if Graph did not return one). + PrincipalDisplay string + + // HasSufficientAIRole is true when the principal has at least + // one of `sufficientAIUserRoles` (Owner, Contributor, Azure AI + // User, Azure AI Developer) on the project scope. + HasSufficientAIRole bool + + // ProjectScope is the full ARM resource ID of the Foundry + // project that was queried. Useful for templating the + // `az role assignment create --scope <...>` remediation. + ProjectScope string + + // AccountName is the Cognitive Services account that contains + // the project (parsed out of ProjectScope). + AccountName string + + // ProjectName is the Foundry project name (parsed out of + // ProjectScope). + ProjectName string +} + +// QueryDeveloperRBAC returns the developer's RBAC posture on the +// given Foundry project resource ID *without* mutating Azure +// state. Unlike CheckDeveloperRBAC, it does not auto-assign +// missing roles and produces no fmt.Println side effects — it +// is intended for diagnostic consumers such as +// `azd ai agent doctor`. +// +// The function performs three round trips: +// +// 1. azd's gRPC `Account.LookupTenant` to resolve the user-access +// tenant for the subscription (multi-tenant / guest users have +// a different user tenant than the resource tenant). +// 2. Microsoft Graph `/me` for the principal's object ID and +// display name. +// 3. ARM `RoleAssignments.ListForScope` with `assignedTo()` filter +// against the project scope. +// +// Errors are surfaced verbatim; callers decide whether to render +// them as Fail, Skip, or Warn in their diagnostic surface. +func QueryDeveloperRBAC( + ctx context.Context, + azdClient *azdext.AzdClient, + projectResourceID string, +) (*DeveloperRBACResult, error) { + info, err := parseAgentIdentityInfo(projectResourceID) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidProjectResourceID, err) + } + + tenantResp, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: info.SubscriptionID, + }) + if err != nil { + return nil, fmt.Errorf("lookup tenant: %w", err) + } + + cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: tenantResp.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return nil, fmt.Errorf("create credential: %w", err) + } + + graphClient, err := graphsdk.NewGraphClient(cred, nil) + if err != nil { + return nil, fmt.Errorf("create graph client: %w", err) + } + + userProfile, err := graphClient.Me().Get(ctx) + if err != nil { + // Graph /me rejects app-only / SPN tokens with a + // canonical "delegated authentication flow" message. + // Surface it as a typed error so doctor can render a + // SPN-aware Skip instead of a generic transient retry. + if isSPNDelegatedAuthError(err) { + return nil, fmt.Errorf("%w: %w", ErrSPNDelegatedAuthRequired, err) + } + return nil, fmt.Errorf("retrieve user profile: %w", err) + } + + hasRole, err := hasAnyRoleAssignment( + ctx, cred, userProfile.Id, sufficientAIUserRoles, info.ProjectScope) + if err != nil { + return nil, fmt.Errorf("list role assignments: %w", err) + } + + return &DeveloperRBACResult{ + PrincipalID: userProfile.Id, + PrincipalDisplay: userProfile.DisplayName, + HasSufficientAIRole: hasRole, + ProjectScope: info.ProjectScope, + AccountName: info.AccountName, + ProjectName: info.ProjectName, + }, nil +} + +// isSPNDelegatedAuthError reports whether the error message matches +// the canonical Microsoft Graph response for a Graph `/me` call +// rejected because the access token was issued to a service +// principal (Graph requires a user-delegated token). The Graph +// response carries `Authorization_RequestDenied` with a message +// fragment `/me request is only valid with delegated authentication +// flow`; the match is intentionally loose against substrings so it +// survives minor wording changes from the Graph service. Falsely +// positive matches simply re-route the error onto the SPN-aware +// Skip message — strictly better than the generic transient retry +// hint either way. +func isSPNDelegatedAuthError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return containsAnyCI(msg, + "/me request is only valid with delegated authentication flow", + "only valid with delegated authentication", + "requires delegated authentication", + ) +} + +// containsAnyCI returns true if any needle (lowercased) appears as a +// substring of haystack (lowercased). Pulled out as a helper because +// the canonical Graph message capitalization has shifted historically +// (e.g., "request" vs "Request") and a case-insensitive match is +// more robust than guessing the current spelling. +func containsAnyCI(haystack string, needles ...string) bool { + lower := strings.ToLower(haystack) + for _, n := range needles { + if strings.Contains(lower, strings.ToLower(n)) { + return true + } + } + return false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index f238bb2e9e4..3254f137b57 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -24,11 +24,13 @@ import ( "strings" "time" + "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/paths" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -133,7 +135,14 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf ) } servicePath := serviceConfig.RelativePath - fullPath := filepath.Join(proj.Project.Path, servicePath) + fullPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid service path for %s: %s", serviceConfig.Name, err), + "update azure.yaml so the agent service path stays within the project directory", + ) + } // Get and store environment azdEnvClient := p.azdClient.Environment() @@ -222,8 +231,22 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf } // Look for agent.yaml or agent.yml in the service directory root - agentYamlPath := filepath.Join(fullPath, "agent.yaml") - agentYmlPath := filepath.Join(fullPath, "agent.yml") + agentYamlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yaml") + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid agent definition path for %s: %s", serviceConfig.Name, err), + "update azure.yaml so the agent definition stays within the project directory", + ) + } + agentYmlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yml") + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid agent definition path for %s: %s", serviceConfig.Name, err), + "update azure.yaml so the agent definition stays within the project directory", + ) + } if _, err := os.Stat(agentYamlPath); err == nil { p.agentDefinitionPath = agentYamlPath @@ -970,6 +993,28 @@ func (p *AgentServiceTargetProvider) finalizeDeploy( protocols, ) + // Best-effort: enrich the last endpoint artifact's note with a + // context-aware "Next:" block. Failures are non-fatal — the static + // aka.ms link emitted by deployArtifacts is preserved when the + // enrichment is skipped or short-circuits. + if state, _ := nextstep.AssembleState(ctx, p.azdClient); state != nil { + // Scope to the service just deployed. ResolveAfterDeploy renders a + // show/invoke pair per state.Services entry; without this filter a + // multi-agent project would attach guidance for other services to + // this artifact's note. + state.Services = filterServicesByName(state.Services, serviceConfig.Name) + + projectRoot := "" + if proj, err := p.azdClient.Project().Get(ctx, nil); err == nil && proj.Project != nil { + projectRoot = proj.Project.Path + } + configDir := "" + if projectRoot != "" && p.env != nil && p.env.Name != "" { + configDir = filepath.Join(projectRoot, ".azure", p.env.Name) + } + augmentDeployNote(state, artifacts, projectRoot, configDir) + } + return &azdext.ServiceDeployResult{ Artifacts: artifacts, }, nil @@ -1529,6 +1574,131 @@ func (p *AgentServiceTargetProvider) deployArtifacts( return artifacts } +// augmentDeployNote enriches the last endpoint artifact's note with a +// context-aware "Next:" block resolved from the provided state. +// +// Collision rule with the static aka.ms link emitted by deployArtifacts: +// +// - When the resolved block contains a "see /README.md" +// suggestion (i.e. a local README exists at the service path), the +// aka.ms line is replaced entirely — the block already points the +// user at the more-detailed local doc, so the canned link is +// redundant. +// - Otherwise the aka.ms line is preserved and the "Next:" block is +// appended below, separated by a blank line — aka.ms remains the +// fallback doc pointer when no local README is present. +// +// The function is a no-op when state is nil, no artifact carries a note, +// or the resolver returns no suggestions; this keeps the deploy path +// resilient to partial state (e.g. project metadata unavailable) without +// silencing the original static guidance. +func augmentDeployNote(state *nextstep.State, artifacts []*azdext.Artifact, projectRoot, configDir string) { + if state == nil { + return + } + + target := lastNoteArtifact(artifacts) + if target == nil { + return + } + + cachedPayload := func(serviceName string) string { + if configDir == "" || serviceName == "" { + return "" + } + spec, err := nextstep.ReadCachedOpenAPISpec(configDir, serviceName, "local") + if err != nil { + return "" + } + return nextstep.ExtractInvokeExample(spec) + } + + readmeExists := func(relativePath string) bool { + if projectRoot == "" { + return false + } + // Only consider the canonical casing — ResolveAfterDeploy emits + // "see /README.md" verbatim. Accepting other casings here + // would yield a broken pointer on case-sensitive filesystems and, + // because suggestionsIncludeReadme triggers the replace branch, + // would silently discard the working aka.ms fallback. + readmePath, err := paths.JoinAllowRoot(projectRoot, relativePath, "README.md") + if err != nil { + return false + } + _, err = os.Stat(readmePath) + return err == nil + } + + suggestions := nextstep.ResolveAfterDeploy(state, cachedPayload, readmeExists) + if len(suggestions) == 0 { + return + } + + block := nextstep.FormatNextForNote(suggestions) + if block == "" { + return + } + + if suggestionsIncludeReadme(suggestions) { + target.Metadata["note"] = block + return + } + existing := target.Metadata["note"] + if existing == "" { + target.Metadata["note"] = block + return + } + target.Metadata["note"] = existing + "\n\n" + block +} + +// lastNoteArtifact returns the last artifact in the slice whose +// Metadata["note"] is non-empty, or nil when none of the artifacts +// carry a note. deployArtifacts attaches its informational note to the +// final endpoint artifact only; scanning from the end keeps this in +// sync should the convention shift to multi-note artifacts in future. +func lastNoteArtifact(artifacts []*azdext.Artifact) *azdext.Artifact { + for i := len(artifacts) - 1; i >= 0; i-- { + a := artifacts[i] + if a == nil || a.Metadata == nil { + continue + } + if a.Metadata["note"] != "" { + return a + } + } + return nil +} + +// suggestionsIncludeReadme reports whether any suggestion is a local-README +// pointer (ResolveAfterDeploy emits these as "see /README.md"). +// Used by augmentDeployNote to decide whether to replace or append to the +// existing static aka.ms note. +func suggestionsIncludeReadme(suggestions []nextstep.Suggestion) bool { + for _, s := range suggestions { + if strings.HasPrefix(s.Command, "see ") && strings.HasSuffix(s.Command, "README.md") { + return true + } + } + return false +} + +// filterServicesByName narrows the assembled state's service slice to a +// single entry by name. Used by the deploy hook so the rendered "Next:" +// block reflects only the service whose artifact note is being augmented, +// not every agent service in the project. +func filterServicesByName(services []nextstep.ServiceState, name string) []nextstep.ServiceState { + if name == "" { + return services + } + for i := range services { + if services[i].Name == name { + return services[i : i+1] + } + } + return nil +} + // protocolEndpointInfo holds a displayable protocol label and its invocation URL. type protocolEndpointInfo struct { Protocol string diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index b9bdb63d807..82be86491c5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -10,9 +10,11 @@ import ( "net" "os" "path/filepath" + "strings" "sync/atomic" "testing" + "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" @@ -229,6 +231,69 @@ func newServiceTargetTestClient( return client } +type stubProjectServer struct { + azdext.UnimplementedProjectServiceServer + project *azdext.ProjectConfig +} + +func (s *stubProjectServer) Get( + context.Context, *azdext.EmptyRequest, +) (*azdext.GetProjectResponse, error) { + return &azdext.GetProjectResponse{Project: s.project}, nil +} + +type stubInitializeEnvServer struct { + azdext.UnimplementedEnvironmentServiceServer +} + +func (s *stubInitializeEnvServer) GetCurrent( + context.Context, *azdext.EmptyRequest, +) (*azdext.EnvironmentResponse, error) { + return &azdext.EnvironmentResponse{Environment: &azdext.Environment{Name: "test-env"}}, nil +} + +func (s *stubInitializeEnvServer) GetValue( + context.Context, *azdext.GetEnvRequest, +) (*azdext.KeyValueResponse, error) { + return &azdext.KeyValueResponse{Value: "00000000-0000-0000-0000-000000000000"}, nil +} + +type stubAccountServer struct { + azdext.UnimplementedAccountServiceServer +} + +func (s *stubAccountServer) LookupTenant( + context.Context, *azdext.LookupTenantRequest, +) (*azdext.LookupTenantResponse, error) { + return &azdext.LookupTenantResponse{TenantId: "00000000-0000-0000-0000-000000000000"}, nil +} + +func newInitializeTestClient(t *testing.T, projectRoot string) *azdext.AzdClient { + t.Helper() + + srv := grpc.NewServer() + azdext.RegisterProjectServiceServer(srv, &stubProjectServer{ + project: &azdext.ProjectConfig{Path: projectRoot}, + }) + azdext.RegisterEnvironmentServiceServer(srv, &stubInitializeEnvServer{}) + azdext.RegisterAccountServiceServer(srv, &stubAccountServer{}) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { + srv.Stop() + _ = lis.Close() + }) + + client, err := azdext.NewAzdClient(azdext.WithAddress(lis.Addr().String())) + require.NoError(t, err) + t.Cleanup(func() { client.Close() }) + + return client +} + type stubPromptServer struct { azdext.UnimplementedPromptServiceServer selectedIndex int32 @@ -254,6 +319,60 @@ func newPromptTestClient(t *testing.T, promptSrv azdext.PromptServiceServer) *az return newServiceTargetTestClient(t, nil, promptSrv) } +func TestInitializeAcceptsProjectLocalAgentYaml(t *testing.T) { + t.Setenv("AGENT_DEFINITION_PATH", "") + + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "svc") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(serviceDir, "agent.yaml"), []byte("kind: hostedAgent\n"), 0o600)) + + provider := &AgentServiceTargetProvider{ + azdClient: newInitializeTestClient(t, projectRoot), + } + + err := provider.Initialize(t.Context(), &azdext.ServiceConfig{Name: "echo", RelativePath: "svc"}) + + require.NoError(t, err) + require.Equal(t, filepath.Join(serviceDir, "agent.yaml"), provider.agentDefinitionPath) +} + +func TestInitializeRejectsAgentYamlSymlinkEscapingRoot(t *testing.T) { + t.Setenv("AGENT_DEFINITION_PATH", "") + + parent := t.TempDir() + projectRoot := filepath.Join(parent, "project") + serviceDir := filepath.Join(projectRoot, "svc") + outside := filepath.Join(parent, "outside") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.MkdirAll(outside, 0o750)) + + outsideAgentYaml := filepath.Join(outside, "agent.yaml") + require.NoError(t, os.WriteFile(outsideAgentYaml, []byte("kind: hostedAgent\n"), 0o600)) + createSymlinkOrSkip(t, outsideAgentYaml, filepath.Join(serviceDir, "agent.yaml")) + + provider := &AgentServiceTargetProvider{ + azdClient: newInitializeTestClient(t, projectRoot), + } + + err := provider.Initialize(t.Context(), &azdext.ServiceConfig{Name: "echo", RelativePath: "svc"}) + + require.Error(t, err) + require.Contains(t, err.Error(), "escapes project root") + require.Empty(t, provider.agentDefinitionPath) +} + +func createSymlinkOrSkip(t *testing.T, oldname, newname string) { + t.Helper() + + if err := os.Symlink(oldname, newname); err != nil { + if errors.Is(err, os.ErrPermission) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("create symlink: %v", err) + } +} + // stubEnvServer records SetValue calls for testing registerAgentEnvironmentVariables. type stubEnvServer struct { azdext.UnimplementedEnvironmentServiceServer @@ -1068,3 +1187,388 @@ func actionableStatusError(t *testing.T, message, suggestion string) error { require.NoError(t, err) return stWithDetails.Err() } + +func TestAugmentDeployNote_NoReadme_AppendsBelowAkaMsLink(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + // No README written; readmeExists closure should return false. + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + { + Name: "echo", + RelativePath: "src/echo", + Protocol: "invocations", + IsDeployed: true, + }, + }, + } + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, "" /* no configDir → cache lookup is a no-op */) + + got := artifact.Metadata["note"] + require.Contains(t, got, "static aka.ms link", "aka.ms link should be preserved when no README is present") + require.Contains(t, got, "Next:", "Next: block should be appended") + require.Contains(t, got, "azd ai agent invoke ", "should suggest invoking the deployed agent") + require.Equal(t, 1, strings.Count(got, "Next:"), "Next: header should appear exactly once") +} + +func TestAugmentDeployNote_WithReadme_ReplacesAkaMsLink(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + servicePath := filepath.Join(tmp, "src", "echo") + require.NoError(t, os.MkdirAll(servicePath, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(servicePath, "README.md"), []byte("sample"), 0o600)) + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + { + Name: "echo", + RelativePath: "src/echo", + Protocol: "invocations", + IsDeployed: true, + }, + }, + } + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, "") + + got := artifact.Metadata["note"] + require.NotContains(t, got, "static aka.ms link", + "aka.ms line must be replaced when a local README provides richer guidance") + require.Contains(t, got, "Next:", "Next: block should be present") + require.Contains(t, got, "see src/echo/README.md", "README pointer should be present") +} + +func TestAugmentDeployNote_WithRootReadme_ReplacesAkaMsLink(t *testing.T) { + t.Parallel() + + for _, rel := range []string{"", "."} { + t.Run(fmt.Sprintf("rel=%q", rel), func(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "README.md"), []byte("sample"), 0o600)) + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + { + Name: "echo", + RelativePath: rel, + Protocol: "invocations", + IsDeployed: true, + }, + }, + } + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, "") + + got := artifact.Metadata["note"] + require.NotContains(t, got, "static aka.ms link", + "aka.ms line must be replaced when a local README provides richer guidance") + require.Contains(t, got, "see README.md", "README pointer should be present") + }) + } +} + +func TestAugmentDeployNote_ReadmeTraversalDoesNotReplaceAkaMsLink(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + projectRoot := filepath.Join(parent, "project") + outside := filepath.Join(parent, "outside") + require.NoError(t, os.MkdirAll(projectRoot, 0o750)) + require.NoError(t, os.MkdirAll(outside, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "README.md"), []byte("outside"), 0o600)) + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + { + Name: "echo", + RelativePath: "../outside", + Protocol: "invocations", + IsDeployed: true, + }, + }, + } + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{artifact}, projectRoot, "") + + got := artifact.Metadata["note"] + require.Contains(t, got, "static aka.ms link") + require.Contains(t, got, "Next:") +} + +func TestAugmentDeployNote_CachedSpecYieldsPayloadOverride(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + configDir := filepath.Join(tmp, ".azure", "dev") + require.NoError(t, os.MkdirAll(configDir, 0o750)) + // ReadCachedOpenAPISpec / sanitizeAgentName: the filename uses the agent + // name verbatim when it contains only safe characters. + spec := `{ + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "example": {"prompt": "from cache"} + } + } + } + } + } + } +}` + require.NoError(t, os.WriteFile(filepath.Join(configDir, "openapi-echo-local.json"), []byte(spec), 0o600)) + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + { + Name: "echo", + RelativePath: "src/echo", + Protocol: "invocations", + IsDeployed: true, + }, + }, + } + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, configDir) + + got := artifact.Metadata["note"] + require.Contains(t, got, `"prompt":"from cache"`, + "cached OpenAPI example should drive the suggested invoke payload") +} + +func TestAugmentDeployNote_NoteAttachedToLastEndpoint(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + { + Name: "echo", + RelativePath: "src/echo", + Protocol: "invocations", + IsDeployed: true, + }, + }, + } + + playground := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{"label": "Agent playground (portal)"}, + } + first := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{"label": "Agent endpoint (responses)"}, + } + last := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{playground, first, last}, tmp, "") + + require.NotContains(t, playground.Metadata["note"], "Next:", "playground artifact must remain untouched") + require.NotContains(t, first.Metadata["note"], "Next:", "non-note endpoint must remain untouched") + require.Contains(t, last.Metadata["note"], "Next:", "augmentation must target the last note-bearing artifact") +} + +func TestAugmentDeployNote_NilStateIsNoOp(t *testing.T) { + t.Parallel() + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + augmentDeployNote(nil, []*azdext.Artifact{artifact}, "/tmp", "") + require.Equal(t, "static aka.ms link", artifact.Metadata["note"], "nil state must leave the static note intact") +} + +func TestAugmentDeployNote_NoNoteBearingArtifactIsNoOp(t *testing.T) { + t.Parallel() + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + {Name: "echo", RelativePath: "src/echo", Protocol: "invocations", IsDeployed: true}, + }, + } + playground := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{"label": "Agent playground (portal)"}, + } + augmentDeployNote(state, []*azdext.Artifact{playground}, "/tmp", "") + require.Empty(t, playground.Metadata["note"], "no note-bearing artifact → nothing to augment") +} + +// TestAugmentDeployNote_NoServicesIsNoOp covers a partial-state branch: +// ResolveAfterDeploy short-circuits on len(state.Services) == 0, so the +// existing static note must survive unchanged. +func TestAugmentDeployNote_NoServicesIsNoOp(t *testing.T) { + t.Parallel() + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + augmentDeployNote(&nextstep.State{}, []*azdext.Artifact{artifact}, "/tmp", "") + require.Equal(t, "static aka.ms link", artifact.Metadata["note"]) +} + +// TestAugmentDeployNote_LowercaseReadme_DoesNotReplaceFallback locks the +// casing-mismatch guard: when only a lowercase readme.md exists on a +// case-sensitive filesystem, the resolver would still emit a literal +// "README.md" pointer that does not resolve on disk and the aka.ms +// fallback would be lost. The fix tightens readmeExists to the canonical +// casing so the append branch fires and the static link is preserved. +func TestAugmentDeployNote_LowercaseReadme_DoesNotReplaceFallback(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + // Detect case-sensitivity at runtime; the fix is meaningful only on + // case-sensitive filesystems (Linux, WSL). On Windows NTFS and default + // macOS APFS the OS resolves "README.md" → "readme.md" transparently, + // which would make readmeExists return true even after the fix. + probe := filepath.Join(tmp, "case-probe.txt") + require.NoError(t, os.WriteFile(probe, nil, 0o600)) + if _, err := os.Stat(filepath.Join(tmp, "CASE-PROBE.TXT")); err == nil { + t.Skip("case-insensitive filesystem — readmeExists casing guard is a no-op here") + } + + servicePath := filepath.Join(tmp, "src", "echo") + require.NoError(t, os.MkdirAll(servicePath, 0o750)) + // Only lowercase readme.md exists; canonical README.md does not. + require.NoError(t, os.WriteFile(filepath.Join(servicePath, "readme.md"), []byte("sample"), 0o600)) + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + { + Name: "echo", + RelativePath: "src/echo", + Protocol: "invocations", + IsDeployed: true, + }, + }, + } + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, "") + + got := artifact.Metadata["note"] + require.Contains(t, got, "static aka.ms link", + "aka.ms fallback must survive when only lowercase readme.md exists on disk") + require.NotContains(t, got, "see src/echo/README.md", + "resolver must not emit a README pointer that does not match what is on disk") +} + +// TestAugmentDeployNote_MultiServiceState_ScopedToDeployedService locks +// the deploy-hook contract that the rendered Next: block reflects only +// the service whose artifact note is being augmented. The hook applies +// filterServicesByName to the assembled state before invoking the +// resolver. +func TestAugmentDeployNote_MultiServiceState_ScopedToDeployedService(t *testing.T) { + t.Parallel() + + state := &nextstep.State{ + Services: []nextstep.ServiceState{ + {Name: "alpha", RelativePath: "src/alpha", Protocol: "invocations", IsDeployed: true}, + {Name: "beta", RelativePath: "src/beta", Protocol: "invocations", IsDeployed: true}, + }, + } + state.Services = filterServicesByName(state.Services, "alpha") + + artifact := &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Metadata: map[string]string{ + "label": "Agent endpoint (invocations)", + "note": "static aka.ms link", + }, + } + + augmentDeployNote(state, []*azdext.Artifact{artifact}, "/tmp", "") + + got := artifact.Metadata["note"] + require.NotContains(t, got, "beta", + "other-service guidance must not leak into the deployed service's note") + require.Contains(t, got, "Next:", "Next: block should be present for the deployed service") +} + +// TestFilterServicesByName covers the helper used at the deploy-hook call site. +func TestFilterServicesByName(t *testing.T) { + t.Parallel() + + services := []nextstep.ServiceState{ + {Name: "alpha"}, + {Name: "beta"}, + {Name: "gamma"}, + } + + require.Equal(t, []nextstep.ServiceState{{Name: "beta"}}, filterServicesByName(services, "beta"), + "match returns single-element slice") + require.Nil(t, filterServicesByName(services, "missing"), + "no match returns nil caller short-circuits on empty Services") + require.Equal(t, services, filterServicesByName(services, ""), + "empty name returns input unchanged (defensive)") +}