From 3e086fdd8e6f19791b89c9de480fe5bc008b91f4 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 5 May 2026 10:13:24 +0530 Subject: [PATCH 01/11] ai agents: --agent-endpoint flag for ephemeral remote invokes Adds a new --agent-endpoint flag to 'azd ai agent invoke' that accepts the full Foundry agent invocation URL printed by 'azd up' / 'azd deploy' and lets the user invoke a deployed agent from any directory without an azd project on disk. * Parses the URL strictly: requires https, the *.services.ai.azure.com Foundry host, the canonical /api/projects/.../agents/.../endpoint/ protocols/[?api-version=...] path, no explicit port, and a non-empty api-version when present. * Derives the protocol (invocations or openai/responses) from the URL and rejects any flags that have no meaning in ephemeral mode (--local, positional name, --port, --protocol, --new-session, --new-conversation). * Body validation runs before bearer-token acquisition so local input errors surface ahead of any auth round-trip. * Prints continuation hints for both server-assigned --session-id and auto-created --conversation-id so users can preserve multi-turn state on the next invoke. * Adds buildResponsesURL / buildInvocationsURL helpers and unit tests covering api-version propagation and URL-encoding of session ids. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/agent_endpoint.go | 226 +++++++++++ .../internal/cmd/agent_endpoint_test.go | 330 +++++++++++++++ .../azure.ai.agents/internal/cmd/invoke.go | 377 ++++++++++++------ .../internal/cmd/invoke_test.go | 91 ++++- 4 files changed, 900 insertions(+), 124 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go new file mode 100644 index 00000000000..5baa0c34489 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" +) + +// agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs. +const agentEndpointHostSuffix = ".services.ai.azure.com" + +// parsedAgentEndpoint describes a deployed agent invocation endpoint. +type parsedAgentEndpoint struct { + // ProjectEndpoint is the Foundry project root: https://.services.ai.azure.com/api/projects/. + ProjectEndpoint string + AgentName string + Protocol agent_api.AgentProtocol + // APIVersion is the api-version query parameter from the URL, or empty if absent. + APIVersion string +} + +// parseAgentEndpoint parses the full agent invocation URL printed by `azd up` / `azd deploy`. +// +// Accepted shapes: +// +// https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations[?api-version=…] +// https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/openai/responses[?api-version=…] +// +// The host must be a `*.services.ai.azure.com` Foundry host. The path must include the +// protocol-specific suffix; the protocol is derived from the URL. +func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { + if strings.TrimSpace(rawURL) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint requires a non-empty URL", + "pass the agent endpoint printed by `azd up` or `azd deploy`", + ) + } + + u, err := url.Parse(rawURL) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid --agent-endpoint URL: %v", err), + "pass the agent endpoint printed by `azd up` or `azd deploy`", + ) + } + + if !strings.EqualFold(u.Scheme, "https") { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint must use https", + "pass the agent endpoint printed by `azd up` or `azd deploy`", + ) + } + + host := strings.ToLower(u.Hostname()) + if host == "" || !strings.HasSuffix(host, agentEndpointHostSuffix) { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostSuffix), + "pass the agent endpoint printed by `azd up` or `azd deploy`", + ) + } + + // Reject explicit ports — Foundry endpoints always use the default HTTPS port, + // and silently dropping a non-default port would route requests to a different origin. + if u.Port() != "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--agent-endpoint host %q must not include a port", u.Host), + "pass the agent endpoint printed by `azd up` or `azd deploy` (no explicit port)", + ) + } + + path := strings.TrimSuffix(u.EscapedPath(), "/") + segments := strings.Split(strings.TrimPrefix(path, "/"), "/") + + // Required prefix: api/projects//agents//endpoint/protocols/ + // Minimum 8 segments (invocations); responses has 9 (openai/responses tail). + if len(segments) < 8 || + segments[0] != "api" || + segments[1] != "projects" || + segments[2] == "" || + segments[3] != "agents" || + segments[4] == "" || + segments[5] != "endpoint" || + segments[6] != "protocols" { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint path must match /api/projects//agents//endpoint/protocols/", + "pass the agent endpoint printed by `azd up` or `azd deploy`", + ) + } + + projectName, err := url.PathUnescape(segments[2]) + if err != nil || projectName == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint project segment is invalid", + "pass the agent endpoint printed by `azd up` or `azd deploy`", + ) + } + + agentName, err := url.PathUnescape(segments[4]) + if err != nil || !isValidAgentNameSegment(agentName) { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentName, + fmt.Sprintf("--agent-endpoint agent name %q is invalid", segments[4]), + "agent names may only contain letters, digits, '-' and '_'", + ) + } + + tail := segments[7:] + var protocol agent_api.AgentProtocol + switch { + case len(tail) == 1 && tail[0] == "invocations": + protocol = agent_api.AgentProtocolInvocations + case len(tail) == 2 && tail[0] == "openai" && tail[1] == "responses": + protocol = agent_api.AgentProtocolResponses + default: + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--agent-endpoint protocol path %q is not recognized", strings.Join(tail, "/")), + "expected '/endpoint/protocols/invocations' or '/endpoint/protocols/openai/responses'", + ) + } + + // Reject an explicit but empty api-version query parameter; the default fallback would + // otherwise silently invoke a different version than the user pasted. + apiVersion := "" + query := u.Query() + if values, present := query["api-version"]; present { + if len(values) == 0 || values[0] == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint api-version query parameter is empty", + "include a non-empty api-version value or omit the parameter to use the default", + ) + } + apiVersion = values[0] + } + + projectEndpoint := fmt.Sprintf("https://%s/api/projects/%s", host, segments[2]) + + return &parsedAgentEndpoint{ + ProjectEndpoint: projectEndpoint, + AgentName: agentName, + Protocol: protocol, + APIVersion: apiVersion, + }, nil +} + +// buildResponsesURL builds the Foundry "openai/responses" protocol URL for an agent. +// apiVersion is URL-encoded so unusual characters cannot break out of the query value. +func buildResponsesURL(projectEndpoint, agentName, apiVersion string) string { + return fmt.Sprintf( + "%s/agents/%s/endpoint/protocols/openai/responses?api-version=%s", + projectEndpoint, agentName, url.QueryEscape(apiVersion), + ) +} + +// buildInvocationsURL builds the Foundry "invocations" protocol URL for an agent. +// When sid is non-empty, an agent_session_id query parameter is appended (URL-encoded). +func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) string { + invURL := fmt.Sprintf( + "%s/agents/%s/endpoint/protocols/invocations?api-version=%s", + projectEndpoint, agentName, url.QueryEscape(apiVersion), + ) + if sid != "" { + invURL += "&agent_session_id=" + url.QueryEscape(sid) + } + return invURL +} + +// isValidAgentNameSegment reports whether s is safe to use as a URL path segment +// without escaping. Allowed characters: ASCII letters, digits, '-' and '_'. +func isValidAgentNameSegment(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '-', r == '_': + default: + return false + } + } + return true +} + +// printEphemeralSessionHint prints a continuation hint after an ephemeral invoke +// when the server assigned a new session ID. It tells the user how to keep the +// next call on the same session. +func printEphemeralSessionHint(currentSid string, resp *http.Response) { + if currentSid != "" || resp == nil { + return + } + newSid := resp.Header.Get("x-agent-session-id") + if newSid == "" { + return + } + fmt.Printf("\nServer assigned session: %s\n", newSid) + fmt.Printf("To continue this session on the next invoke, pass: --session-id %s\n", newSid) +} + +// printEphemeralConversationHint prints a continuation hint after an ephemeral +// invoke when the CLI auto-created a Foundry conversation. It tells the user +// how to keep multi-turn memory on the next invoke, since ephemeral mode does +// not persist conversation state anywhere. +func printEphemeralConversationHint(currentConvID, createdConvID string) { + if currentConvID != "" || createdConvID == "" { + return + } + fmt.Printf("To continue this conversation on the next invoke, pass: --conversation-id %s\n", createdConvID) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go new file mode 100644 index 00000000000..112ab1e11cb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -0,0 +1,330 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" +) + +func TestParseAgentEndpoint(t *testing.T) { + tests := []struct { + name string + raw string + wantProj string + wantAgent string + wantProto agent_api.AgentProtocol + wantAPIVer string + wantErr bool + errContains string + }{ + { + name: "invocations with api-version", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations?api-version=2025-11-15-preview", + wantProj: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgent: "hello", + wantProto: agent_api.AgentProtocolInvocations, + wantAPIVer: "2025-11-15-preview", + }, + { + name: "invocations without api-version", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations", + wantProj: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgent: "hello", + wantProto: agent_api.AgentProtocolInvocations, + }, + { + name: "responses (openai/responses)", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/echo/endpoint/protocols/openai/responses?api-version=2025-11-15-preview", + wantProj: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgent: "echo", + wantProto: agent_api.AgentProtocolResponses, + wantAPIVer: "2025-11-15-preview", + }, + { + name: "trailing slash tolerated", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations/", + wantProj: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgent: "hello", + wantProto: agent_api.AgentProtocolInvocations, + }, + { + name: "empty url", + raw: "", + wantErr: true, + errContains: "non-empty URL", + }, + { + name: "http scheme rejected", + raw: "http://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations", + wantErr: true, + errContains: "https", + }, + { + name: "non-foundry host rejected", + raw: "https://evil.com/api/projects/proj/agents/hello/endpoint/protocols/invocations", + wantErr: true, + errContains: "Foundry host", + }, + { + name: "host suffix injection rejected", + raw: "https://services.ai.azure.com.evil.com/api/projects/proj/agents/hello/endpoint/protocols/invocations", + wantErr: true, + errContains: "Foundry host", + }, + { + name: "missing api/projects prefix", + raw: "https://acct.services.ai.azure.com/agents/hello/endpoint/protocols/invocations", + wantErr: true, + errContains: "path must match", + }, + { + name: "unknown protocol tail", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/grpc", + wantErr: true, + errContains: "protocol path", + }, + { + name: "missing protocol tail", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols", + wantErr: true, + errContains: "path must match", + }, + { + name: "invalid agent name (chars)", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hel%20lo/endpoint/protocols/invocations", + wantErr: true, + errContains: "agent name", + }, + { + name: "malformed url", + raw: "https://%zz/foo", + wantErr: true, + errContains: "invalid", + }, + { + name: "explicit port rejected", + raw: "https://acct.services.ai.azure.com:444/api/projects/proj/agents/hello/endpoint/protocols/invocations", + wantErr: true, + errContains: "must not include a port", + }, + { + name: "empty api-version rejected", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations?api-version=", + wantErr: true, + errContains: "api-version query parameter is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAgentEndpoint(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil; result=%+v", got) + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ProjectEndpoint != tt.wantProj { + t.Errorf("ProjectEndpoint = %q, want %q", got.ProjectEndpoint, tt.wantProj) + } + if got.AgentName != tt.wantAgent { + t.Errorf("AgentName = %q, want %q", got.AgentName, tt.wantAgent) + } + if got.Protocol != tt.wantProto { + t.Errorf("Protocol = %q, want %q", got.Protocol, tt.wantProto) + } + if got.APIVersion != tt.wantAPIVer { + t.Errorf("APIVersion = %q, want %q", got.APIVersion, tt.wantAPIVer) + } + }) + } +} + +func TestIsValidAgentNameSegment(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"hello", true}, + {"hello-world", true}, + {"agent_v2", true}, + {"AGENT123", true}, + {"", false}, + {"hello world", false}, + {"hello/world", false}, + {"hello.world", false}, + {"agent@v1", false}, + {"agent:v1", false}, + {"agent*", false}, + {"agent!", false}, + {"héllo", false}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := isValidAgentNameSegment(tt.in); got != tt.want { + t.Errorf("isValidAgentNameSegment(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestPrintEphemeralSessionHint(t *testing.T) { + t.Run("no current sid, server returns sid -> hint", func(t *testing.T) { + resp := &http.Response{Header: http.Header{"X-Agent-Session-Id": []string{"sess-123"}}} + out := captureStdout(t, func() { printEphemeralSessionHint("", resp) }) + if !strings.Contains(out, "Server assigned session: sess-123") { + t.Errorf("missing 'Server assigned session' line in output: %q", out) + } + if !strings.Contains(out, "--session-id sess-123") { + t.Errorf("missing '--session-id sess-123' continuation hint in output: %q", out) + } + }) + t.Run("with existing sid -> no hint", func(t *testing.T) { + resp := &http.Response{Header: http.Header{"X-Agent-Session-Id": []string{"sess-123"}}} + out := captureStdout(t, func() { printEphemeralSessionHint("user-sid", resp) }) + if out != "" { + t.Errorf("expected no output when caller already has a session id, got %q", out) + } + }) + t.Run("nil response -> no hint", func(t *testing.T) { + out := captureStdout(t, func() { printEphemeralSessionHint("", nil) }) + if out != "" { + t.Errorf("expected no output for nil response, got %q", out) + } + }) + t.Run("response without session header -> no hint", func(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + out := captureStdout(t, func() { printEphemeralSessionHint("", resp) }) + if out != "" { + t.Errorf("expected no output when server returns no session id, got %q", out) + } + }) + t.Run("via http test server", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("x-agent-session-id", "real-server-sid") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + resp, err := http.Get(srv.URL) //nolint:gosec // test server + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + out := captureStdout(t, func() { printEphemeralSessionHint("", resp) }) + if !strings.Contains(out, "real-server-sid") { + t.Errorf("expected hint to include server-assigned sid, got %q", out) + } + }) +} + +func TestPrintEphemeralConversationHint(t *testing.T) { + t.Run("auto-created conversation -> hint", func(t *testing.T) { + out := captureStdout(t, func() { printEphemeralConversationHint("", "conv-abc") }) + if !strings.Contains(out, "--conversation-id conv-abc") { + t.Errorf("missing '--conversation-id conv-abc' continuation hint in output: %q", out) + } + }) + t.Run("user supplied --conversation-id -> no hint", func(t *testing.T) { + out := captureStdout(t, func() { printEphemeralConversationHint("user-conv", "conv-abc") }) + if out != "" { + t.Errorf("expected no output when caller already has a conversation id, got %q", out) + } + }) + t.Run("no created conversation -> no hint", func(t *testing.T) { + out := captureStdout(t, func() { printEphemeralConversationHint("", "") }) + if out != "" { + t.Errorf("expected no output when no conversation id was created, got %q", out) + } + }) +} + +// captureStdout runs fn while redirecting os.Stdout and returns whatever was written. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + _ = w.Close() + return <-done +} + +// TestBuildResponsesURL verifies that the responses URL builder uses the parsed +// api-version (rather than the default fallback) and URL-encodes it. +func TestBuildResponsesURL(t *testing.T) { + parsed, err := parseAgentEndpoint( + "https://acct.services.ai.azure.com/api/projects/proj/agents/echo/endpoint/protocols/openai/responses?api-version=2025-11-15-preview", + ) + if err != nil { + t.Fatalf("parseAgentEndpoint: %v", err) + } + got := buildResponsesURL(parsed.ProjectEndpoint, parsed.AgentName, parsed.APIVersion) + want := "https://acct.services.ai.azure.com/api/projects/proj/agents/echo/endpoint/protocols/openai/responses?api-version=2025-11-15-preview" + if got != want { + t.Errorf("buildResponsesURL = %q, want %q", got, want) + } + + // api-version must be query-escaped so unusual characters cannot break out. + gotEscaped := buildResponsesURL("https://acct.services.ai.azure.com/api/projects/proj", "echo", "weird value&x=1") + if !strings.Contains(gotEscaped, "api-version=weird+value%26x%3D1") { + t.Errorf("buildResponsesURL did not escape api-version: %q", gotEscaped) + } +} + +// TestBuildInvocationsURL verifies that the invocations URL builder propagates +// the parsed api-version, URL-encodes it, and URL-encodes any session id. +func TestBuildInvocationsURL(t *testing.T) { + parsed, err := parseAgentEndpoint( + "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations?api-version=2025-11-15-preview", + ) + if err != nil { + t.Fatalf("parseAgentEndpoint: %v", err) + } + + t.Run("no session id", func(t *testing.T) { + got := buildInvocationsURL(parsed.ProjectEndpoint, parsed.AgentName, parsed.APIVersion, "") + want := "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations?api-version=2025-11-15-preview" + if got != want { + t.Errorf("buildInvocationsURL = %q, want %q", got, want) + } + }) + + t.Run("session id is escaped", func(t *testing.T) { + got := buildInvocationsURL(parsed.ProjectEndpoint, parsed.AgentName, parsed.APIVersion, "a b/c?d&e") + if !strings.Contains(got, "agent_session_id=a+b%2Fc%3Fd%26e") { + t.Errorf("buildInvocationsURL did not escape session id: %q", got) + } + }) + + t.Run("api-version is escaped", func(t *testing.T) { + got := buildInvocationsURL("https://acct.services.ai.azure.com/api/projects/proj", "hello", "weird value&x=1", "") + if !strings.Contains(got, "api-version=weird+value%26x%3D1") { + t.Errorf("buildInvocationsURL did not escape api-version: %q", got) + } + }) +} 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 9f65bffe856..7863f7f420b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -38,11 +38,13 @@ type invokeFlags struct { conversation string newConversation bool protocol string + agentEndpoint string } type InvokeAction struct { flags *invokeFlags noPrompt bool + endpoint *parsedAgentEndpoint } func newInvokeCommand(extCtx *azdext.ExtensionContext) *cobra.Command { @@ -87,7 +89,12 @@ session automatically. Pass --new-session to force a reset.`, azd ai agent invoke --local "Hello!" # Start a new session (discard conversation history) - azd ai agent invoke --new-session "Hello!"`, + azd ai agent invoke --new-session "Hello!" + + # Invoke a deployed agent from any directory using the endpoint URL printed by 'azd up' + azd ai agent invoke \ + --agent-endpoint https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations?api-version=2025-11-15-preview \ + "Hello!"`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) @@ -107,6 +114,23 @@ session automatically. Pass --new-session to force a reset.`, // Only valid when -f is provided } + action := &InvokeAction{flags: flags, noPrompt: extCtx.NoPrompt} + + // Agent-endpoint structural conflicts are surfaced first so the user sees + // the precise reason their invocation cannot proceed. + if flags.agentEndpoint != "" { + if err := validateAgentEndpointFlags(cmd, flags); err != nil { + return err + } + parsed, err := parseAgentEndpoint(flags.agentEndpoint) + if err != nil { + return err + } + flags.protocol = string(parsed.Protocol) + flags.name = parsed.AgentName + action.endpoint = parsed + } + if flags.inputFile != "" && flags.message != "" { return exterrors.Validation( exterrors.CodeInvalidParameter, @@ -144,10 +168,6 @@ session automatically. Pass --new-session to force a reset.`, } } - action := &InvokeAction{ - flags: flags, - noPrompt: extCtx.NoPrompt, - } return action.Run(ctx) }, } @@ -161,10 +181,63 @@ session automatically. Pass --new-session to force a reset.`, cmd.Flags().BoolVar(&flags.newSession, "new-session", false, "Force a new session (discard saved one)") cmd.Flags().StringVar(&flags.conversation, "conversation-id", "", "Explicit conversation ID override") cmd.Flags().BoolVar(&flags.newConversation, "new-conversation", false, "Force a new conversation (discard saved one)") + cmd.Flags().StringVar( + &flags.agentEndpoint, + "agent-endpoint", + "", + "Full endpoint URL of a deployed agent (printed by 'azd up' / 'azd deploy'). "+ + "Invokes without requiring an azd project; protocol is derived from the URL.", + ) return cmd } +// validateAgentEndpointFlags rejects flags that have no effect (or conflict) when --agent-endpoint +// is used. Ephemeral mode has no project, no local persistence, and no localhost target. +func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { + switch { + case flags.local: + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint cannot be combined with --local", + "omit --local to invoke the deployed agent at the given URL", + ) + case flags.name != "": + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint cannot be combined with a positional agent name", + "the agent name is read from the --agent-endpoint URL; remove the positional argument", + ) + case cmd.Flags().Changed("port"): + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint cannot be combined with --port", + "--port targets a local agent; omit it when using --agent-endpoint", + ) + case cmd.Flags().Changed("protocol"): + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint cannot be combined with --protocol", + "the protocol is read from the --agent-endpoint URL; omit --protocol", + ) + case flags.newSession: + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint cannot be combined with --new-session", + "ephemeral invokes do not persist session IDs; omit --new-session "+ + "or pass --session-id to continue a known session", + ) + case flags.newConversation: + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint cannot be combined with --new-conversation", + "ephemeral invokes do not persist conversation IDs; omit --new-conversation "+ + "or pass --conversation-id to continue a known conversation", + ) + } + return nil +} + func (a *InvokeAction) Run(ctx context.Context) error { protocol, err := a.resolveProtocol(ctx) if err != nil { @@ -342,29 +415,106 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { return printAgentResponse(result, "local") } -func (a *InvokeAction) responsesRemote(ctx context.Context) error { +// remoteContext holds the resolved inputs for a remote (Foundry) invoke. +// In ephemeral mode (--agent-endpoint), AzdClient is nil and the project endpoint / +// agent name / api-version come from the parsed URL. +type remoteContext struct { + name string + agentEndpoint string // full Foundry agent endpoint URL; used to derive the global-config agentKey + projectEndpoint string + apiVersion string + azdClient *azdext.AzdClient + bearerToken string +} + +// 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 +// deferred to acquireBearerToken so callers can validate the request body first +// and avoid unnecessary token round-trips on invalid input. Callers must close +// rc.azdClient when non-nil. +func (a *InvokeAction) resolveRemoteContext(ctx context.Context) (*remoteContext, error) { + rc := &remoteContext{apiVersion: DefaultAgentAPIVersion} + + if a.endpoint != nil { + rc.name = a.endpoint.AgentName + rc.projectEndpoint = a.endpoint.ProjectEndpoint + if a.endpoint.APIVersion != "" { + rc.apiVersion = a.endpoint.APIVersion + } + return rc, nil + } + azdClient, err := azdext.NewAzdClient() if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) + return nil, fmt.Errorf("failed to create azd client: %w", err) } - defer azdClient.Close() + rc.azdClient = azdClient - name := a.flags.name - var agentEndpoint string - - // Auto-resolve agent name and version from azure.yaml - if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, a.noPrompt); err == nil { - if name == "" && info.AgentName != "" { - name = info.AgentName + rc.name = a.flags.name + if info, err := resolveAgentServiceFromProject(ctx, azdClient, rc.name, a.noPrompt); err == nil { + if rc.name == "" && info.AgentName != "" { + rc.name = info.AgentName } - agentEndpoint = info.AgentEndpoint + rc.agentEndpoint = info.AgentEndpoint + } + if rc.name == "" { + azdClient.Close() + return nil, fmt.Errorf( + "agent name is required; provide as the first argument or " + + "define an azure.ai.agent service in azure.yaml", + ) + } + + ep, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + azdClient.Close() + return nil, err + } + rc.projectEndpoint = ep + return rc, nil +} + +// acquireBearerToken obtains a Foundry bearer token. Called after request body +// validation so that local errors (e.g., a missing --input-file) are surfaced +// before any auth round-trip is attempted. +func (a *InvokeAction) acquireBearerToken(ctx context.Context) (string, error) { + credential, err := newAgentCredential() + if err != nil { + return "", err + } + token, err := credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://ai.azure.com/.default"}, + }) + if err != nil { + return "", ephemeralAuthError(a.endpoint != nil, err) } + return token.Token, nil +} - if name == "" { - return fmt.Errorf("agent name is required; provide as the first argument or define an azure.ai.agent service in azure.yaml") +// ephemeralAuthError wraps a token-acquisition failure with a login suggestion when +// the user is invoking outside an azd project (where mis-configured credentials are common). +func ephemeralAuthError(ephemeral bool, err error) error { + if !ephemeral { + return fmt.Errorf("failed to get auth token: %w", err) } + return exterrors.Auth( + exterrors.CodeAuthFailed, + fmt.Sprintf("failed to get auth token: %v", err), + "run `azd auth login` and try again", + ) +} - projectEndpoint, err := resolveAgentEndpoint(ctx, "", "") +func (a *InvokeAction) responsesRemote(ctx context.Context) error { + rc, err := a.resolveRemoteContext(ctx) + if err != nil { + return err + } + if rc.azdClient != nil { + defer rc.azdClient.Close() + } + + body, bodyLabel, err := a.resolveBody() if err != nil { return err } @@ -372,13 +522,16 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { // Build the structured agent key for config store lookups. // When the endpoint is unavailable (pre-deploy), skip session/conversation persistence. var agentKey string - if agentEndpoint != "" { - agentKey = buildRemoteAgentKeyFromEndpoint(agentEndpoint) - } else { + if rc.agentEndpoint != "" { + agentKey = buildRemoteAgentKeyFromEndpoint(rc.agentEndpoint) + } else if rc.azdClient != nil { log.Printf("warning: agent endpoint not available, session state will not be persisted") } - body, bodyLabel, err := a.resolveBody() + // Acquire the bearer token after body validation so a local input error + // (e.g., unreadable --input-file) does not pay an unnecessary auth round-trip + // and is surfaced before any auth failure. + rc.bearerToken, err = a.acquireBearerToken(ctx) if err != nil { return err } @@ -394,52 +547,49 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { // Session ID — routes to the same microVM container instance. // When empty, let the server assign one. var sid string - if agentKey != "" { + if agentKey != "" && rc.azdClient != nil { sid, err = resolveStoredID( - ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", false, - legacyKeysForRemote(name)..., + ctx, rc.azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", false, + legacyKeysForRemote(rc.name)..., ) if err != nil { return err } - } else if a.flags.session != "" { + } else { sid = a.flags.session } if sid != "" { reqBody["session_id"] = sid } - // Acquire credential and token — used for both conversation creation and the invoke request - credential, err := newAgentCredential() - if err != nil { - return err - } - - token, err := credential.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: []string{"https://ai.azure.com/.default"}, - }) - if err != nil { - return fmt.Errorf("failed to get auth token: %w", err) - } - - // Conversation ID — enables multi-turn memory via Foundry Conversations API - convID, err := resolveConversationID( - ctx, - azdClient, - agentKey, - a.flags.conversation, - a.flags.newConversation, - projectEndpoint, - token.Token, - name, - legacyKeysForRemote(name)..., - ) - if err != nil { - return err + // Conversation ID — enables multi-turn memory via Foundry Conversations API. + var convID string + if agentKey != "" && rc.azdClient != nil { + convID, err = resolveConversationID( + ctx, + rc.azdClient, + agentKey, + a.flags.conversation, + a.flags.newConversation, + rc.projectEndpoint, + rc.bearerToken, + rc.name, + legacyKeysForRemote(rc.name)..., + ) + if err != nil { + return err + } + } else if a.flags.conversation != "" { + convID = a.flags.conversation + } else { + convID, err = createConversation(ctx, rc.projectEndpoint, rc.name, rc.bearerToken) + if err != nil { + return err + } } reqBody["conversation"] = map[string]string{"id": convID} - fmt.Printf("Agent: %s (remote)\n", name) + fmt.Printf("Agent: %s (remote)\n", rc.name) fmt.Printf("Message: %s\n", bodyLabel) printSessionStatus("Session: ", sid) fmt.Printf("Conversation: %s\n", convID) @@ -450,19 +600,17 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { return fmt.Errorf("failed to marshal request: %w", err) } - url := fmt.Sprintf( - "%s/agents/%s/endpoint/protocols/openai/responses?api-version=%s", - projectEndpoint, name, DefaultAgentAPIVersion, - ) + url := buildResponsesURL(rc.projectEndpoint, rc.name, rc.apiVersion) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token.Token) + req.Header.Set("Authorization", "Bearer "+rc.bearerToken) client := &http.Client{Timeout: a.httpTimeout()} - resp, err := client.Do(req) //nolint:gosec // G704: endpoint is resolved from azd environment configuration + //nolint:gosec // G704: URL is built from a validated Foundry endpoint (env or --agent-endpoint) + resp, err := client.Do(req) if err != nil { return fmt.Errorf("POST %s failed: %w", url, err) } @@ -473,7 +621,11 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { fmt.Printf("Trace ID: %s\n", requestID) } - captureResponseSession(ctx, azdClient, agentKey, sid, resp, "Session: ") + captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, "Session: ") + if rc.azdClient == nil { + printEphemeralSessionHint(sid, resp) + printEphemeralConversationHint(a.flags.conversation, convID) + } if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) @@ -481,7 +633,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { } // Parse SSE stream for agent output - return readSSEStream(resp.Body, name) + return readSSEStream(resp.Body, rc.name) } func (a *InvokeAction) invocationsLocal(ctx context.Context) error { @@ -555,107 +707,90 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { // invocationsRemote sends the user's message to Foundry using // the invocations protocol (POST /agents/{name}/endpoint/protocols/invocations). func (a *InvokeAction) invocationsRemote(ctx context.Context) error { - azdClient, err := azdext.NewAzdClient() + rc, err := a.resolveRemoteContext(ctx) if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) + return err } - defer azdClient.Close() - - name := a.flags.name - var agentEndpoint string - - // Auto-resolve agent name from azure.yaml / azd environment - if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, a.noPrompt); err == nil { - if name == "" && info.AgentName != "" { - name = info.AgentName - } - agentEndpoint = info.AgentEndpoint + if rc.azdClient != nil { + defer rc.azdClient.Close() } - if name == "" { - return fmt.Errorf( - "agent name is required; provide as the first argument or define an azure.ai.agent service in azure.yaml", - ) + var agentKey string + if rc.agentEndpoint != "" { + agentKey = buildRemoteAgentKeyFromEndpoint(rc.agentEndpoint) + } else if rc.azdClient != nil { + log.Printf("warning: agent endpoint not available, session state will not be persisted") } - endpoint, err := resolveAgentEndpoint(ctx, "", "") + body, bodyLabel, err := a.resolveBody() if err != nil { return err } - var agentKey string - if agentEndpoint != "" { - agentKey = buildRemoteAgentKeyFromEndpoint(agentEndpoint) - } else { - log.Printf("warning: agent endpoint not available, session state will not be persisted") - } - - body, bodyLabel, err := a.resolveBody() + // Acquire the bearer token after body validation so a local input error + // (e.g., unreadable --input-file) does not pay an unnecessary auth round-trip + // and is surfaced before any auth failure. + rc.bearerToken, err = a.acquireBearerToken(ctx) if err != nil { return err } - // Session ID — routes to the same container instance + // Session ID — routes to the same container instance. var sid string - if agentKey != "" { - sid, err = resolveStoredID(ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", false) + if agentKey != "" && rc.azdClient != nil { + sid, err = resolveStoredID( + ctx, rc.azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", false, + legacyKeysForRemote(rc.name)..., + ) if err != nil { return err } - } else if a.flags.session != "" { + } else { sid = a.flags.session } - // Acquire credential and token - credential, err := newAgentCredential() - if err != nil { - return err - } - - token, err := credential.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: []string{"https://ai.azure.com/.default"}, - }) - if err != nil { - return fmt.Errorf("failed to get auth token: %w", err) - } - - fmt.Printf("Agent: %s (remote, invocations protocol)\n", name) + fmt.Printf("Agent: %s (remote, invocations protocol)\n", rc.name) fmt.Printf("Input: %s\n", bodyLabel) printSessionStatus("Session: ", sid) fmt.Println() - remoteBaseURL := fmt.Sprintf("%s/agents/%s/endpoint/protocols", endpoint, name) - - // Fetch and cache the agent's OpenAPI spec (skip if already cached). - fetchOpenAPISpec(ctx, azdClient, remoteBaseURL, name, "remote", token.Token, false) + remoteBaseURL := fmt.Sprintf("%s/agents/%s/endpoint/protocols", rc.projectEndpoint, rc.name) - invURL := fmt.Sprintf("%s/invocations?api-version=%s", remoteBaseURL, DefaultAgentAPIVersion) - if sid != "" { - invURL += "&agent_session_id=" + url.QueryEscape(sid) + // Fetch and cache the agent's OpenAPI spec (skip if already cached, no-op without azd client). + if rc.azdClient != nil { + fetchOpenAPISpec(ctx, rc.azdClient, remoteBaseURL, rc.name, "remote", rc.bearerToken, false) } + invURL := buildInvocationsURL(rc.projectEndpoint, rc.name, rc.apiVersion, sid) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, invURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", contentTypeForBody(body)) - req.Header.Set("Authorization", "Bearer "+token.Token) + req.Header.Set("Authorization", "Bearer "+rc.bearerToken) client := &http.Client{Timeout: a.httpTimeout()} - resp, err := client.Do(req) //nolint:gosec // G704: endpoint is resolved from azd environment configuration + //nolint:gosec // G704: URL is built from a validated Foundry endpoint (env or --agent-endpoint) + resp, err := client.Do(req) if err != nil { return fmt.Errorf("POST %s failed: %w", invURL, err) } defer resp.Body.Close() - // Print the invocation ID if the agent returned one. - if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" { - fmt.Printf("Invocation: %s\n", invID) + // Persist the most recent invocation ID for this agent (project mode only). + if agentKey != "" && rc.azdClient != nil { + if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" { + saveContextValue(ctx, rc.azdClient, agentKey, invID, "invocations") + } } - captureResponseSession(ctx, azdClient, agentKey, sid, resp, "Session: ") + captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, "Session: ") + if rc.azdClient == nil { + printEphemeralSessionHint(sid, resp) + } - return handleInvocationResponse(ctx, resp, endpoint, token.Token, name, a.httpTimeout()) + return handleInvocationResponse(ctx, resp, rc.projectEndpoint, rc.bearerToken, rc.name, a.httpTimeout()) } // handleInvocationResponse dispatches the response from a POST /invocations call diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go index b5bdda30971..d9c33622fb8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go @@ -342,6 +342,93 @@ func TestProtocolFlagValidation(t *testing.T) { } } +// TestAgentEndpointFlagValidation covers the up-front validation rules for --agent-endpoint. +// These run before any network call, so they exercise the cobra RunE error path directly. +func TestAgentEndpointFlagValidation(t *testing.T) { + t.Parallel() + + const validURL = "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations?api-version=2025-11-15-preview" + + tests := []struct { + name string + args []string + wantErr bool + errSub string + }{ + { + name: "rejects --local", + args: []string{"--agent-endpoint", validURL, "--local", "hi"}, + wantErr: true, + errSub: "cannot be combined with --local", + }, + { + name: "rejects positional name", + args: []string{"--agent-endpoint", validURL, "myagent", "hi"}, + wantErr: true, + errSub: "positional agent name", + }, + { + name: "rejects --port", + args: []string{"--agent-endpoint", validURL, "--port", "9999", "hi"}, + wantErr: true, + errSub: "cannot be combined with --port", + }, + { + name: "rejects explicit --port at default value", + args: []string{"--agent-endpoint", validURL, "--port", "8088", "hi"}, + wantErr: true, + errSub: "cannot be combined with --port", + }, + { + name: "rejects --new-session", + args: []string{"--agent-endpoint", validURL, "--new-session", "hi"}, + wantErr: true, + errSub: "cannot be combined with --new-session", + }, + { + name: "rejects --new-conversation", + args: []string{"--agent-endpoint", validURL, "--new-conversation", "hi"}, + wantErr: true, + errSub: "cannot be combined with --new-conversation", + }, + { + name: "rejects --protocol", + args: []string{"--agent-endpoint", validURL, "--protocol", "responses", "hi"}, + wantErr: true, + errSub: "cannot be combined with --protocol", + }, + { + name: "rejects --protocol even when matching", + args: []string{"--agent-endpoint", validURL, "--protocol", "invocations", "hi"}, + wantErr: true, + errSub: "cannot be combined with --protocol", + }, + { + name: "rejects malformed url", + args: []string{"--agent-endpoint", "https://evil.com/foo", "hi"}, + wantErr: true, + errSub: "Foundry host", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cmd := newInvokeCommand(nil) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) { + t.Errorf("error %q should contain %q", err.Error(), tt.errSub) + } + } + }) + } +} + func TestHandleInvocationSync(t *testing.T) { t.Parallel() @@ -927,9 +1014,7 @@ func TestCreateConversation(t *testing.T) { })) defer srv.Close() - id, err := createConversation( - t.Context(), srv.URL, tt.agentName, "test-token", - ) + id, err := createConversation(t.Context(), srv.URL, tt.agentName, "test-token") if tt.wantErr { if err == nil { From f3009f7579b4165c71c468c7689e6382a0bb079d Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 5 May 2026 10:35:04 +0530 Subject: [PATCH 02/11] ai agents: address review feedback on --agent-endpoint - agent_endpoint.go: replace ad-hoc segment-by-segment path validation with a single regex match (matches existing projectResourceIdRegex style elsewhere in the package). - agent_endpoint.go: introduce agentEndpointHint constant and use it everywhere the previous 'pass the agent endpoint printed by azd up or azd deploy' message appeared. Now points users at 'azd ai agent show', which persistently prints the endpoint URL. - invoke.go: collapse validateAgentEndpointFlags' six-case switch into a generic table-driven loop over disallowed flags. - agent_endpoint_test.go: update unknown_protocol_tail expectation to match the unified regex error message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/agent_endpoint.go | 70 +++++++++--------- .../internal/cmd/agent_endpoint_test.go | 2 +- .../azure.ai.agents/internal/cmd/invoke.go | 74 +++++++++---------- 3 files changed, 72 insertions(+), 74 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index 5baa0c34489..e1ed5413000 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strings" "azureaiagent/internal/exterrors" @@ -16,6 +17,20 @@ import ( // agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs. const agentEndpointHostSuffix = ".services.ai.azure.com" +// agentEndpointHint is the suggestion appended to most --agent-endpoint validation errors. +// `azd ai agent show` persistently prints the agent endpoint URL, so it's the right +// thing to point users at any time after a deploy. +const agentEndpointHint = "run `azd ai agent show` to see the agent endpoint URL" + +// agentEndpointPathRegex matches the full Foundry agent-endpoint path. Captures: +// +// [1] project name (URL-escaped), +// [2] agent name (URL-escaped), +// [3] protocol tail ("invocations" or "openai/responses"). +var agentEndpointPathRegex = regexp.MustCompile( + `^/api/projects/([^/]+)/agents/([^/]+)/endpoint/protocols/(invocations|openai/responses)/?$`, +) + // parsedAgentEndpoint describes a deployed agent invocation endpoint. type parsedAgentEndpoint struct { // ProjectEndpoint is the Foundry project root: https://.services.ai.azure.com/api/projects/. @@ -26,7 +41,7 @@ type parsedAgentEndpoint struct { APIVersion string } -// parseAgentEndpoint parses the full agent invocation URL printed by `azd up` / `azd deploy`. +// parseAgentEndpoint parses the full agent invocation URL printed by `azd ai agent show`. // // Accepted shapes: // @@ -40,7 +55,7 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-endpoint requires a non-empty URL", - "pass the agent endpoint printed by `azd up` or `azd deploy`", + agentEndpointHint, ) } @@ -49,7 +64,7 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("invalid --agent-endpoint URL: %v", err), - "pass the agent endpoint printed by `azd up` or `azd deploy`", + agentEndpointHint, ) } @@ -57,7 +72,7 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-endpoint must use https", - "pass the agent endpoint printed by `azd up` or `azd deploy`", + agentEndpointHint, ) } @@ -66,7 +81,7 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostSuffix), - "pass the agent endpoint printed by `azd up` or `azd deploy`", + agentEndpointHint, ) } @@ -76,61 +91,46 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("--agent-endpoint host %q must not include a port", u.Host), - "pass the agent endpoint printed by `azd up` or `azd deploy` (no explicit port)", + agentEndpointHint+" (no explicit port)", ) } - path := strings.TrimSuffix(u.EscapedPath(), "/") - segments := strings.Split(strings.TrimPrefix(path, "/"), "/") - - // Required prefix: api/projects//agents//endpoint/protocols/ - // Minimum 8 segments (invocations); responses has 9 (openai/responses tail). - if len(segments) < 8 || - segments[0] != "api" || - segments[1] != "projects" || - segments[2] == "" || - segments[3] != "agents" || - segments[4] == "" || - segments[5] != "endpoint" || - segments[6] != "protocols" { + // Match the full path against the canonical Foundry agent-endpoint shape and pull + // the project name, agent name, and protocol tail out in one pass. + matches := agentEndpointPathRegex.FindStringSubmatch(u.EscapedPath()) + if matches == nil { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-endpoint path must match /api/projects//agents//endpoint/protocols/", - "pass the agent endpoint printed by `azd up` or `azd deploy`", + agentEndpointHint, ) } + projectSegment, agentSegment, protocolTail := matches[1], matches[2], matches[3] - projectName, err := url.PathUnescape(segments[2]) + projectName, err := url.PathUnescape(projectSegment) if err != nil || projectName == "" { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-endpoint project segment is invalid", - "pass the agent endpoint printed by `azd up` or `azd deploy`", + agentEndpointHint, ) } - agentName, err := url.PathUnescape(segments[4]) + agentName, err := url.PathUnescape(agentSegment) if err != nil || !isValidAgentNameSegment(agentName) { return nil, exterrors.Validation( exterrors.CodeInvalidAgentName, - fmt.Sprintf("--agent-endpoint agent name %q is invalid", segments[4]), + fmt.Sprintf("--agent-endpoint agent name %q is invalid", agentSegment), "agent names may only contain letters, digits, '-' and '_'", ) } - tail := segments[7:] var protocol agent_api.AgentProtocol - switch { - case len(tail) == 1 && tail[0] == "invocations": + switch protocolTail { + case "invocations": protocol = agent_api.AgentProtocolInvocations - case len(tail) == 2 && tail[0] == "openai" && tail[1] == "responses": + case "openai/responses": protocol = agent_api.AgentProtocolResponses - default: - return nil, exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("--agent-endpoint protocol path %q is not recognized", strings.Join(tail, "/")), - "expected '/endpoint/protocols/invocations' or '/endpoint/protocols/openai/responses'", - ) } // Reject an explicit but empty api-version query parameter; the default fallback would @@ -148,7 +148,7 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { apiVersion = values[0] } - projectEndpoint := fmt.Sprintf("https://%s/api/projects/%s", host, segments[2]) + projectEndpoint := fmt.Sprintf("https://%s/api/projects/%s", host, projectSegment) return &parsedAgentEndpoint{ ProjectEndpoint: projectEndpoint, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go index 112ab1e11cb..1115570214b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -89,7 +89,7 @@ func TestParseAgentEndpoint(t *testing.T) { name: "unknown protocol tail", raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/grpc", wantErr: true, - errContains: "protocol path", + errContains: "path must match", }, { name: "missing protocol tail", 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 7863f7f420b..359841efc08 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -91,7 +91,7 @@ session automatically. Pass --new-session to force a reset.`, # Start a new session (discard conversation history) azd ai agent invoke --new-session "Hello!" - # Invoke a deployed agent from any directory using the endpoint URL printed by 'azd up' + # Invoke a deployed agent from any directory using the endpoint URL shown by 'azd ai agent show' azd ai agent invoke \ --agent-endpoint https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations?api-version=2025-11-15-preview \ "Hello!"`, @@ -185,7 +185,7 @@ session automatically. Pass --new-session to force a reset.`, &flags.agentEndpoint, "agent-endpoint", "", - "Full endpoint URL of a deployed agent (printed by 'azd up' / 'azd deploy'). "+ + "Full endpoint URL of a deployed agent (run 'azd ai agent show' to see it). "+ "Invokes without requiring an azd project; protocol is derived from the URL.", ) @@ -195,45 +195,43 @@ session automatically. Pass --new-session to force a reset.`, // validateAgentEndpointFlags rejects flags that have no effect (or conflict) when --agent-endpoint // is used. Ephemeral mode has no project, no local persistence, and no localhost target. func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { - switch { - case flags.local: - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--agent-endpoint cannot be combined with --local", - "omit --local to invoke the deployed agent at the given URL", - ) - case flags.name != "": - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--agent-endpoint cannot be combined with a positional agent name", + // Disallowed companion flags for --agent-endpoint, in the order checked. + // `set` is true when the flag is meaningfully present on the command line. + checks := []struct { + name string + set bool + suggestion string + }{ + {"--local", flags.local, "omit --local to invoke the deployed agent at the given URL"}, + { + "a positional agent name", + flags.name != "", "the agent name is read from the --agent-endpoint URL; remove the positional argument", - ) - case cmd.Flags().Changed("port"): - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--agent-endpoint cannot be combined with --port", - "--port targets a local agent; omit it when using --agent-endpoint", - ) - case cmd.Flags().Changed("protocol"): - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--agent-endpoint cannot be combined with --protocol", - "the protocol is read from the --agent-endpoint URL; omit --protocol", - ) - case flags.newSession: - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--agent-endpoint cannot be combined with --new-session", - "ephemeral invokes do not persist session IDs; omit --new-session "+ + }, + {"--port", cmd.Flags().Changed("port"), "--port targets a local agent; omit it when using --agent-endpoint"}, + {"--protocol", cmd.Flags().Changed("protocol"), "the protocol is read from the --agent-endpoint URL; omit --protocol"}, + { + "--new-session", + flags.newSession, + "ephemeral invokes do not persist session IDs; omit --new-session " + "or pass --session-id to continue a known session", - ) - case flags.newConversation: - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--agent-endpoint cannot be combined with --new-conversation", - "ephemeral invokes do not persist conversation IDs; omit --new-conversation "+ + }, + { + "--new-conversation", + flags.newConversation, + "ephemeral invokes do not persist conversation IDs; omit --new-conversation " + "or pass --conversation-id to continue a known conversation", - ) + }, + } + + for _, c := range checks { + if c.set { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--agent-endpoint cannot be combined with %s", c.name), + c.suggestion, + ) + } } return nil } From c22c1831f063ced0b711de26605d9f94bc40b0fb Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 5 May 2026 10:49:28 +0530 Subject: [PATCH 03/11] ai agents: address multi-model code review findings - invoke.go: restore the `Invocation:` print line that was lost during the rebase. Previously, `invocationsRemote` always printed the `x-agent-invocation-id` header so users could correlate the call for tracing. The rebased version only persisted it (and only in project mode), so `--agent-endpoint` callers and project-mode callers both lost the visible handle. Restore the print and keep the persist as an extra step in project mode. - agent_endpoint.go: reject `%2F` (or other encoded path separators) inside the project segment of `--agent-endpoint`. The regex captures `[^/]+` against the escaped path, so an encoded slash slipped through validation and `url.PathUnescape` then materialized a literal `/` in the project name. Add a `ContainsAny(name, "/\\")` check to match the strictness already applied to the agent segment. Add a test case covering `proj%2Fother`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/agent_endpoint.go | 2 +- .../azure.ai.agents/internal/cmd/agent_endpoint_test.go | 6 ++++++ cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go | 7 ++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index e1ed5413000..abfa83d430f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -108,7 +108,7 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { projectSegment, agentSegment, protocolTail := matches[1], matches[2], matches[3] projectName, err := url.PathUnescape(projectSegment) - if err != nil || projectName == "" { + if err != nil || projectName == "" || strings.ContainsAny(projectName, "/\\") { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-endpoint project segment is invalid", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go index 1115570214b..3f7ca299741 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -103,6 +103,12 @@ func TestParseAgentEndpoint(t *testing.T) { wantErr: true, errContains: "agent name", }, + { + name: "encoded slash in project segment rejected", + raw: "https://acct.services.ai.azure.com/api/projects/proj%2Fother/agents/hello/endpoint/protocols/invocations", + wantErr: true, + errContains: "project segment is invalid", + }, { name: "malformed url", raw: "https://%zz/foo", 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 359841efc08..57c5b312020 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -776,9 +776,10 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { } defer resp.Body.Close() - // Persist the most recent invocation ID for this agent (project mode only). - if agentKey != "" && rc.azdClient != nil { - if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" { + // Print the invocation ID if the agent returned one, and persist it in project mode. + if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" { + fmt.Printf("Invocation: %s\n", invID) + if agentKey != "" && rc.azdClient != nil { saveContextValue(ctx, rc.azdClient, agentKey, invID, "invocations") } } From 81652a0aa733ae965d6277b55cfef34a81ec41e5 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 5 May 2026 11:46:44 +0530 Subject: [PATCH 04/11] ai agents: enable global-config persistence for --agent-endpoint After PR #8034 the session/conversation store is keyed by an endpoint-derived agentKey in global UserConfig (env-independent). This wires --agent-endpoint into that store so ephemeral invokes auto-resume across calls. Changes: - Add buildEphemeralAgentKey: a stable, query-string-free key derived from the parsed projectEndpoint+agentName. Distinct '/ephemeral' suffix so it never collides with project-mode '/remote' keys. - resolveRemoteContext (ephemeral): best-effort attach the parent azd daemon and set rc.agentKey. Standalone runs (no daemon) silently fall back to no-persistence. - responsesRemote / invocationsRemote: drop the local agentKey computation; use rc.agentKey. Tighten OpenAPI-spec fetch to project mode only (no on-disk side effect for ephemeral). - Drop --new-session / --new-conversation from validateAgentEndpointFlags so users can reset stored IDs in ephemeral mode. Add warnIneffectiveResetFlags to log a no-op warning when standalone. - Continuation hints now require resp.StatusCode<400 so failed invokes don't tell users to continue a never-created conversation (review feedback). - Hint gate widened to (agentKey == '' || azdClient == nil) so it fires whenever persistence is genuinely unavailable, not just when the daemon is missing. Tests: TestBuildEphemeralAgentKey covers URL-variant stability (canonical, trailing-slash, mixed case host) and the project-mode key-collision contract. The two dropped --new-* validation cases removed from TestAgentEndpointFlagValidation. Live-verified against the deployed responses agent: invokes 1+2 share session+conversation IDs and the agent recalls prior turns; invoke 3 with --new-session --new-conversation produces fresh IDs; invoke 4 with a URL variant (no ?api-version) hits the same persisted entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/config_store.go | 12 +++ .../internal/cmd/config_store_test.go | 46 +++++++++ .../azure.ai.agents/internal/cmd/invoke.go | 98 ++++++++++++------- .../internal/cmd/invoke_test.go | 12 --- 4 files changed, 123 insertions(+), 45 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go index f65b9a81aca..53eed0032dd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go @@ -244,6 +244,18 @@ func buildRemoteAgentKeyFromEndpoint(agentEndpoint string) string { return normalizeEndpoint(agentEndpoint) + "/remote" } +// buildEphemeralAgentKey constructs a stable persistence key for --agent-endpoint +// invokes. It is derived from the parsed endpoint components rather than the raw +// URL so that variants of the same logical endpoint (with/without ?api-version, +// trailing slash, fragment, host casing) collapse to the same key. +// +// The "/ephemeral" suffix is intentionally distinct from project-mode "/remote" +// keys: the two have different lifecycles (ephemeral is anchored at endpoint+name +// only and never embeds a deploy version/api-version). +func buildEphemeralAgentKey(projectEndpoint, agentName string) string { + return fmt.Sprintf("%s/agents/%s/ephemeral", normalizeEndpoint(projectEndpoint), agentName) +} + // buildLocalAgentKey constructs a key for local mode. // projectPath is used to disambiguate across different projects using the same port. func buildLocalAgentKey(port int, agentName, version, projectPath string) string { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go index 27ce0ce9b97..747faa1ce16 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go @@ -117,6 +117,52 @@ func TestBuildRemoteAgentKeyFromEndpoint_EquivalentToBuildAgentKey(t *testing.T) require.Equal(t, fromParts, fromEndpoint) } +// TestBuildEphemeralAgentKey_StableAcrossURLVariants ensures that all URL +// shapes that the user could plausibly pass to --agent-endpoint (canonical, +// with/without api-version, trailing slash, fragment, mixed case host) +// collapse to the same persistence key. Cross-variant drift would silently +// fragment session/conversation state on every invoke. +func TestBuildEphemeralAgentKey_StableAcrossURLVariants(t *testing.T) { + t.Parallel() + + const ( + projectEndpoint = "https://acct.services.ai.azure.com/api/projects/proj" + agentName = "my-agent" + ) + want := "acct.services.ai.azure.com/api/projects/proj/agents/my-agent/ephemeral" + + tests := []struct { + name string + endpoint string + }{ + {"canonical", projectEndpoint}, + {"trailing slash", projectEndpoint + "/"}, + {"mixed case host", "https://Acct.Services.AI.Azure.Com/api/projects/proj"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := buildEphemeralAgentKey(tt.endpoint, agentName) + assert.Equal(t, want, got) + }) + } +} + +// TestBuildEphemeralAgentKey_DistinctFromRemote guards the contract that +// ephemeral keys never collide with project-mode keys for the same endpoint. +func TestBuildEphemeralAgentKey_DistinctFromRemote(t *testing.T) { + t.Parallel() + + const ( + projectEndpoint = "https://acct.services.ai.azure.com/api/projects/proj" + agentName = "my-agent" + ) + ephemeral := buildEphemeralAgentKey(projectEndpoint, agentName) + remote := buildAgentKey(projectEndpoint, agentName, "1", false) + assert.NotEqual(t, ephemeral, remote) +} + func TestNormalizeEndpoint_StripScheme(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 57c5b312020..4ac81d143b2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -210,18 +210,6 @@ func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { }, {"--port", cmd.Flags().Changed("port"), "--port targets a local agent; omit it when using --agent-endpoint"}, {"--protocol", cmd.Flags().Changed("protocol"), "the protocol is read from the --agent-endpoint URL; omit --protocol"}, - { - "--new-session", - flags.newSession, - "ephemeral invokes do not persist session IDs; omit --new-session " + - "or pass --session-id to continue a known session", - }, - { - "--new-conversation", - flags.newConversation, - "ephemeral invokes do not persist conversation IDs; omit --new-conversation " + - "or pass --conversation-id to continue a known conversation", - }, } for _, c := range checks { @@ -236,6 +224,21 @@ func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { return nil } +// warnIneffectiveResetFlags logs when --new-session / --new-conversation were +// requested but persistence is not active (standalone mode without a parent azd +// daemon). The flags only have an effect when the extension can read/write the +// stored IDs, so silently accepting them would be misleading. +func warnIneffectiveResetFlags(flags *invokeFlags) { + if flags.newSession { + log.Printf("warning: --new-session has no effect without a parent azd daemon (session ID is not persisted)") + } + if flags.newConversation { + log.Printf( + "warning: --new-conversation has no effect without a parent azd daemon (conversation ID is not persisted)", + ) + } +} + func (a *InvokeAction) Run(ctx context.Context) error { protocol, err := a.resolveProtocol(ctx) if err != nil { @@ -414,11 +417,21 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { } // remoteContext holds the resolved inputs for a remote (Foundry) invoke. -// In ephemeral mode (--agent-endpoint), AzdClient is nil and the project endpoint / -// agent name / api-version come from the parsed URL. +// In ephemeral mode (--agent-endpoint) the project endpoint / agent name / +// api-version come from the parsed URL. +// +// agentKey is the persistence key used by the global UserConfig store. It is +// non-empty whenever session/conversation IDs should be saved or resumed: +// - project mode: derived from AGENT_{SVC}_ENDPOINT +// - ephemeral mode: derived from the parsed --agent-endpoint URL +// (independent of api-version / trailing slash / fragment) +// +// In standalone mode (no parent azd daemon, e.g. running the extension binary +// directly outside an azd command) azdClient is nil and persistence helpers +// no-op. agentKey may still be non-empty in that case. type remoteContext struct { name string - agentEndpoint string // full Foundry agent endpoint URL; used to derive the global-config agentKey + agentKey string projectEndpoint string apiVersion string azdClient *azdext.AzdClient @@ -440,6 +453,13 @@ func (a *InvokeAction) resolveRemoteContext(ctx context.Context) (*remoteContext if a.endpoint.APIVersion != "" { rc.apiVersion = a.endpoint.APIVersion } + rc.agentKey = buildEphemeralAgentKey(a.endpoint.ProjectEndpoint, a.endpoint.AgentName) + // Best-effort attach to the parent azd daemon so session/conversation IDs + // persist across invokes via global UserConfig. When running the extension + // binary directly (standalone), this fails and we proceed without persistence. + if azdClient, err := azdext.NewAzdClient(); err == nil { + rc.azdClient = azdClient + } return rc, nil } @@ -454,7 +474,9 @@ func (a *InvokeAction) resolveRemoteContext(ctx context.Context) (*remoteContext if rc.name == "" && info.AgentName != "" { rc.name = info.AgentName } - rc.agentEndpoint = info.AgentEndpoint + if info.AgentEndpoint != "" { + rc.agentKey = buildRemoteAgentKeyFromEndpoint(info.AgentEndpoint) + } } if rc.name == "" { azdClient.Close() @@ -517,14 +539,16 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { return err } - // Build the structured agent key for config store lookups. - // When the endpoint is unavailable (pre-deploy), skip session/conversation persistence. - var agentKey string - if rc.agentEndpoint != "" { - agentKey = buildRemoteAgentKeyFromEndpoint(rc.agentEndpoint) - } else if rc.azdClient != nil { + // agentKey is set by resolveRemoteContext when persistence is applicable. + // In ephemeral mode without a parent azd daemon we still set it for clarity, + // but the helpers below early-return when azdClient is nil. + agentKey := rc.agentKey + if agentKey == "" && rc.azdClient != nil { log.Printf("warning: agent endpoint not available, session state will not be persisted") } + if a.endpoint != nil && rc.azdClient == nil { + warnIneffectiveResetFlags(a.flags) + } // Acquire the bearer token after body validation so a local input error // (e.g., unreadable --input-file) does not pay an unnecessary auth round-trip @@ -620,16 +644,20 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { } captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, "Session: ") - if rc.azdClient == nil { - printEphemeralSessionHint(sid, resp) - printEphemeralConversationHint(a.flags.conversation, convID) - } if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("POST %s failed with HTTP %d: %s\n%s", url, resp.StatusCode, resp.Status, string(respBody)) } + // Continuation hints are only emitted on success: a failed invoke would not + // have produced a usable conversation to continue. Persistence still happens + // above (captureResponseSession) so a successful retry can resume. + if agentKey == "" || rc.azdClient == nil { + printEphemeralSessionHint(sid, resp) + printEphemeralConversationHint(a.flags.conversation, convID) + } + // Parse SSE stream for agent output return readSSEStream(resp.Body, rc.name) } @@ -713,12 +741,13 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { defer rc.azdClient.Close() } - var agentKey string - if rc.agentEndpoint != "" { - agentKey = buildRemoteAgentKeyFromEndpoint(rc.agentEndpoint) - } else if rc.azdClient != nil { + agentKey := rc.agentKey + if agentKey == "" && rc.azdClient != nil { log.Printf("warning: agent endpoint not available, session state will not be persisted") } + if a.endpoint != nil && rc.azdClient == nil { + warnIneffectiveResetFlags(a.flags) + } body, bodyLabel, err := a.resolveBody() if err != nil { @@ -754,8 +783,10 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { remoteBaseURL := fmt.Sprintf("%s/agents/%s/endpoint/protocols", rc.projectEndpoint, rc.name) - // Fetch and cache the agent's OpenAPI spec (skip if already cached, no-op without azd client). - if rc.azdClient != nil { + // Fetch and cache the agent's OpenAPI spec only in project mode. In ephemeral + // 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) } @@ -785,7 +816,8 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { } captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, "Session: ") - if rc.azdClient == nil { + // Continuation hint is only emitted on success; see comment in responsesRemote. + if (agentKey == "" || rc.azdClient == nil) && resp.StatusCode < 400 { printEphemeralSessionHint(sid, resp) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go index d9c33622fb8..27aa72d4ab7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go @@ -379,18 +379,6 @@ func TestAgentEndpointFlagValidation(t *testing.T) { wantErr: true, errSub: "cannot be combined with --port", }, - { - name: "rejects --new-session", - args: []string{"--agent-endpoint", validURL, "--new-session", "hi"}, - wantErr: true, - errSub: "cannot be combined with --new-session", - }, - { - name: "rejects --new-conversation", - args: []string{"--agent-endpoint", validURL, "--new-conversation", "hi"}, - wantErr: true, - errSub: "cannot be combined with --new-conversation", - }, { name: "rejects --protocol", args: []string{"--agent-endpoint", validURL, "--protocol", "responses", "hi"}, From 084cbf7157e09c269ffc3ed8438bb35aca60706c Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 5 May 2026 12:13:36 +0530 Subject: [PATCH 05/11] ai agents: replace ephemeral hint fallback with help-pointer tip The standalone-mode hint code (printEphemeralSessionHint / printEphemeralConversationHint) only fired when the parent azd daemon was unreachable -- a path that does not occur in normal user flow (zd ai agent invoke ... always spawns the daemon). With persistence working under #8034 those hints were essentially dead code. Remove both helpers (and their tests / unused captureStdout helper / net/http import) and replace with a single concise tip printed after a successful invoke when persistence is active in both responsesRemote and invocationsRemote: (tip: pass --new-session or --new-conversation to reset; see `azd ai agent invoke --help`) Tests + lint clean. Live verified end-of-output ordering against hello-world-python-responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/agent_endpoint.go | 27 ------ .../internal/cmd/agent_endpoint_test.go | 96 ------------------- .../azure.ai.agents/internal/cmd/invoke.go | 26 ++--- 3 files changed, 14 insertions(+), 135 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index abfa83d430f..2c82dfcbd03 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -5,7 +5,6 @@ package cmd import ( "fmt" - "net/http" "net/url" "regexp" "strings" @@ -198,29 +197,3 @@ func isValidAgentNameSegment(s string) bool { } return true } - -// printEphemeralSessionHint prints a continuation hint after an ephemeral invoke -// when the server assigned a new session ID. It tells the user how to keep the -// next call on the same session. -func printEphemeralSessionHint(currentSid string, resp *http.Response) { - if currentSid != "" || resp == nil { - return - } - newSid := resp.Header.Get("x-agent-session-id") - if newSid == "" { - return - } - fmt.Printf("\nServer assigned session: %s\n", newSid) - fmt.Printf("To continue this session on the next invoke, pass: --session-id %s\n", newSid) -} - -// printEphemeralConversationHint prints a continuation hint after an ephemeral -// invoke when the CLI auto-created a Foundry conversation. It tells the user -// how to keep multi-turn memory on the next invoke, since ephemeral mode does -// not persist conversation state anywhere. -func printEphemeralConversationHint(currentConvID, createdConvID string) { - if currentConvID != "" || createdConvID == "" { - return - } - fmt.Printf("To continue this conversation on the next invoke, pass: --conversation-id %s\n", createdConvID) -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go index 3f7ca299741..fad92a78839 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -4,10 +4,6 @@ package cmd import ( - "io" - "net/http" - "net/http/httptest" - "os" "strings" "testing" @@ -188,98 +184,6 @@ func TestIsValidAgentNameSegment(t *testing.T) { } } -func TestPrintEphemeralSessionHint(t *testing.T) { - t.Run("no current sid, server returns sid -> hint", func(t *testing.T) { - resp := &http.Response{Header: http.Header{"X-Agent-Session-Id": []string{"sess-123"}}} - out := captureStdout(t, func() { printEphemeralSessionHint("", resp) }) - if !strings.Contains(out, "Server assigned session: sess-123") { - t.Errorf("missing 'Server assigned session' line in output: %q", out) - } - if !strings.Contains(out, "--session-id sess-123") { - t.Errorf("missing '--session-id sess-123' continuation hint in output: %q", out) - } - }) - t.Run("with existing sid -> no hint", func(t *testing.T) { - resp := &http.Response{Header: http.Header{"X-Agent-Session-Id": []string{"sess-123"}}} - out := captureStdout(t, func() { printEphemeralSessionHint("user-sid", resp) }) - if out != "" { - t.Errorf("expected no output when caller already has a session id, got %q", out) - } - }) - t.Run("nil response -> no hint", func(t *testing.T) { - out := captureStdout(t, func() { printEphemeralSessionHint("", nil) }) - if out != "" { - t.Errorf("expected no output for nil response, got %q", out) - } - }) - t.Run("response without session header -> no hint", func(t *testing.T) { - resp := &http.Response{Header: http.Header{}} - out := captureStdout(t, func() { printEphemeralSessionHint("", resp) }) - if out != "" { - t.Errorf("expected no output when server returns no session id, got %q", out) - } - }) - t.Run("via http test server", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("x-agent-session-id", "real-server-sid") - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - resp, err := http.Get(srv.URL) //nolint:gosec // test server - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - out := captureStdout(t, func() { printEphemeralSessionHint("", resp) }) - if !strings.Contains(out, "real-server-sid") { - t.Errorf("expected hint to include server-assigned sid, got %q", out) - } - }) -} - -func TestPrintEphemeralConversationHint(t *testing.T) { - t.Run("auto-created conversation -> hint", func(t *testing.T) { - out := captureStdout(t, func() { printEphemeralConversationHint("", "conv-abc") }) - if !strings.Contains(out, "--conversation-id conv-abc") { - t.Errorf("missing '--conversation-id conv-abc' continuation hint in output: %q", out) - } - }) - t.Run("user supplied --conversation-id -> no hint", func(t *testing.T) { - out := captureStdout(t, func() { printEphemeralConversationHint("user-conv", "conv-abc") }) - if out != "" { - t.Errorf("expected no output when caller already has a conversation id, got %q", out) - } - }) - t.Run("no created conversation -> no hint", func(t *testing.T) { - out := captureStdout(t, func() { printEphemeralConversationHint("", "") }) - if out != "" { - t.Errorf("expected no output when no conversation id was created, got %q", out) - } - }) -} - -// captureStdout runs fn while redirecting os.Stdout and returns whatever was written. -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe: %v", err) - } - orig := os.Stdout - os.Stdout = w - defer func() { os.Stdout = orig }() - - done := make(chan string, 1) - go func() { - b, _ := io.ReadAll(r) - done <- string(b) - }() - - fn() - _ = w.Close() - return <-done -} - // TestBuildResponsesURL verifies that the responses URL builder uses the parsed // api-version (rather than the default fallback) and URL-encodes it. func TestBuildResponsesURL(t *testing.T) { 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 4ac81d143b2..8897ad16ee6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -650,16 +650,15 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { return fmt.Errorf("POST %s failed with HTTP %d: %s\n%s", url, resp.StatusCode, resp.Status, string(respBody)) } - // Continuation hints are only emitted on success: a failed invoke would not - // have produced a usable conversation to continue. Persistence still happens - // above (captureResponseSession) so a successful retry can resume. - if agentKey == "" || rc.azdClient == nil { - printEphemeralSessionHint(sid, resp) - printEphemeralConversationHint(a.flags.conversation, convID) + // Parse SSE stream for agent output + if err := readSSEStream(resp.Body, rc.name); err != nil { + return err } - // Parse SSE stream for agent output - return readSSEStream(resp.Body, rc.name) + if agentKey != "" && rc.azdClient != nil { + fmt.Println("\n(tip: pass --new-session or --new-conversation to reset; see `azd ai agent invoke --help`)") + } + return nil } func (a *InvokeAction) invocationsLocal(ctx context.Context) error { @@ -816,12 +815,15 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { } captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, "Session: ") - // Continuation hint is only emitted on success; see comment in responsesRemote. - if (agentKey == "" || rc.azdClient == nil) && resp.StatusCode < 400 { - printEphemeralSessionHint(sid, resp) + + if err := handleInvocationResponse(ctx, resp, rc.projectEndpoint, rc.bearerToken, rc.name, a.httpTimeout()); err != nil { + return err } - return handleInvocationResponse(ctx, resp, rc.projectEndpoint, rc.bearerToken, rc.name, a.httpTimeout()) + if agentKey != "" && rc.azdClient != nil { + fmt.Println("\n(tip: pass --new-session or --new-conversation to reset; see `azd ai agent invoke --help`)") + } + return nil } // handleInvocationResponse dispatches the response from a POST /invocations call From 8f9456c212c59a5755972c419d779d8d3a33d70f Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 5 May 2026 12:37:26 +0530 Subject: [PATCH 06/11] azure.ai.agents: protocol-aware reset hint for invoke The post-invoke tip and reset-flag handling now match each protocol's actual memory model: - Responses protocol keeps the existing tip mentioning both --new-session and --new-conversation (it uses the Foundry Conversations API for multi-turn memory). - Invocations protocol prints a tip that only mentions --new-session, since memory is bound to the session and --new-conversation has no observable effect on this path. - If the user does pass --new-conversation while invoking an invocations endpoint, a stderr note explains that the flag is a no-op for this protocol and points to --new-session instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 8897ad16ee6..dd2ea7da4c4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -748,6 +748,12 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { warnIneffectiveResetFlags(a.flags) } + if a.flags.newConversation { + fmt.Fprintln(os.Stderr, + "note: --new-conversation has no effect for the invocations protocol "+ + "(memory is bound to the session; use --new-session to reset).") + } + body, bodyLabel, err := a.resolveBody() if err != nil { return err @@ -821,7 +827,7 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { } if agentKey != "" && rc.azdClient != nil { - fmt.Println("\n(tip: pass --new-session or --new-conversation to reset; see `azd ai agent invoke --help`)") + fmt.Println("\n(tip: pass --new-session to reset; see `azd ai agent invoke --help`)") } return nil } From 42af9d18ed56e0efbc5f54d0cdb6b3798d2a9658 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 5 May 2026 12:54:16 +0530 Subject: [PATCH 07/11] azure.ai.agents: address PR review findings on --agent-endpoint - agent_endpoint.go: agent-name validation now delegates to agent_yaml.ValidateAgentName so --agent-endpoint enforces the same deployable-name format as the rest of the extension. Previously underscores and unbounded lengths slipped through local validation only to fail later as 404s. The bespoke isValidAgentNameSegment helper and its unit test are removed; a new TestParseAgentEndpoint_RejectsInvalidAgentNames covers underscore, length>63, and leading/trailing hyphen rejection. - invoke.go (warnIneffectiveResetFlags): switched from log.Printf to fmt.Fprintln(os.Stderr, ...). The extension silences the standard logger unless debug mode is enabled (setupDebugLogging redirects to io.Discard), so the previous warning never reached users in standalone --agent-endpoint mode. - invoke.go (invocationsRemote): removed the saveContextValue(..., "invocations") call. validateStoreField only allows "sessions" and "conversations", so the persistence call always failed silently. The invocation ID is still printed for trace correlation; we just no longer pretend to persist it. - config_store.go: rewrote a doc comment to use "scopes" instead of "lifecycles" so the cspell pipeline passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/agent_endpoint.go | 27 +++--------- .../internal/cmd/agent_endpoint_test.go | 44 +++++++++---------- .../internal/cmd/config_store.go | 2 +- .../azure.ai.agents/internal/cmd/invoke.go | 28 +++++++----- 4 files changed, 46 insertions(+), 55 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index 2c82dfcbd03..f6fceb434d1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -11,6 +11,7 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" ) // agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs. @@ -116,11 +117,12 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { } agentName, err := url.PathUnescape(agentSegment) - if err != nil || !isValidAgentNameSegment(agentName) { + if err != nil || agent_yaml.ValidateAgentName(agentName) != nil { return nil, exterrors.Validation( exterrors.CodeInvalidAgentName, fmt.Sprintf("--agent-endpoint agent name %q is invalid", agentSegment), - "agent names may only contain letters, digits, '-' and '_'", + "agent names must start and end with an alphanumeric character, "+ + "may contain hyphens in the middle, and be 1-63 characters long", ) } @@ -179,21 +181,6 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str return invURL } -// isValidAgentNameSegment reports whether s is safe to use as a URL path segment -// without escaping. Allowed characters: ASCII letters, digits, '-' and '_'. -func isValidAgentNameSegment(s string) bool { - if s == "" { - return false - } - for _, r := range s { - switch { - case r >= 'a' && r <= 'z', - r >= 'A' && r <= 'Z', - r >= '0' && r <= '9', - r == '-', r == '_': - default: - return false - } - } - return true -} +// (isValidAgentNameSegment was removed — agent name validation now delegates +// to agent_yaml.ValidateAgentName so --agent-endpoint enforces the same +// deployable-name format as the rest of the extension.) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go index fad92a78839..cabb033f43e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -156,29 +156,29 @@ func TestParseAgentEndpoint(t *testing.T) { } } -func TestIsValidAgentNameSegment(t *testing.T) { - tests := []struct { - in string - want bool - }{ - {"hello", true}, - {"hello-world", true}, - {"agent_v2", true}, - {"AGENT123", true}, - {"", false}, - {"hello world", false}, - {"hello/world", false}, - {"hello.world", false}, - {"agent@v1", false}, - {"agent:v1", false}, - {"agent*", false}, - {"agent!", false}, - {"héllo", false}, +// TestParseAgentEndpoint_RejectsInvalidAgentNames covers names that pass the +// regex's `[^/]+` capture but fail the canonical agent_yaml.ValidateAgentName +// check (which enforces the deployable-name format). Without this delegation +// these inputs would previously have been accepted locally and only failed +// later as 404s on the wire. +func TestParseAgentEndpoint_RejectsInvalidAgentNames(t *testing.T) { + cases := []string{ + // underscore — disallowed by the canonical validator + "agent_v2", + // 64 characters — exceeds the 63-char limit + strings.Repeat("a", 64), + // trailing hyphen — must end alphanumeric + "agent-", + // leading hyphen — must start alphanumeric + "-agent", } - for _, tt := range tests { - t.Run(tt.in, func(t *testing.T) { - if got := isValidAgentNameSegment(tt.in); got != tt.want { - t.Errorf("isValidAgentNameSegment(%q) = %v, want %v", tt.in, got, tt.want) + for _, name := range cases { + t.Run(name, func(t *testing.T) { + endpoint := "https://acct.services.ai.azure.com/api/projects/proj/agents/" + + name + "/endpoint/protocols/invocations?api-version=2025-11-15-preview" + _, err := parseAgentEndpoint(endpoint) + if err == nil { + t.Fatalf("parseAgentEndpoint(%q) = nil, want error", name) } }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go index 53eed0032dd..85ae6d858f2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go @@ -250,7 +250,7 @@ func buildRemoteAgentKeyFromEndpoint(agentEndpoint string) string { // trailing slash, fragment, host casing) collapse to the same key. // // The "/ephemeral" suffix is intentionally distinct from project-mode "/remote" -// keys: the two have different lifecycles (ephemeral is anchored at endpoint+name +// keys: the two have different scopes (ephemeral is anchored at endpoint+name // only and never embeds a deploy version/api-version). func buildEphemeralAgentKey(projectEndpoint, agentName string) string { return fmt.Sprintf("%s/agents/%s/ephemeral", normalizeEndpoint(projectEndpoint), agentName) 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 dd2ea7da4c4..642e938e06c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -224,18 +224,21 @@ func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { return nil } -// warnIneffectiveResetFlags logs when --new-session / --new-conversation were -// requested but persistence is not active (standalone mode without a parent azd -// daemon). The flags only have an effect when the extension can read/write the -// stored IDs, so silently accepting them would be misleading. +// warnIneffectiveResetFlags prints to stderr when --new-session / +// --new-conversation were requested but persistence is not active (standalone +// mode without a parent azd daemon). The flags only have an effect when the +// extension can read/write the stored IDs, so silently accepting them would be +// misleading. We use stderr (not the std logger) because the extension +// silences `log` output unless debug mode is enabled. func warnIneffectiveResetFlags(flags *invokeFlags) { if flags.newSession { - log.Printf("warning: --new-session has no effect without a parent azd daemon (session ID is not persisted)") + fmt.Fprintln(os.Stderr, + "warning: --new-session has no effect without a parent azd daemon (session ID is not persisted)") } if flags.newConversation { - log.Printf( - "warning: --new-conversation has no effect without a parent azd daemon (conversation ID is not persisted)", - ) + fmt.Fprintln(os.Stderr, + "warning: --new-conversation has no effect without a parent azd daemon "+ + "(conversation ID is not persisted)") } } @@ -812,12 +815,13 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { } defer resp.Body.Close() - // Print the invocation ID if the agent returned one, and persist it in project mode. + // Print the invocation ID if the agent returned one. We do not persist it + // to the per-user config: the config store only supports the "sessions" + // and "conversations" maps (see validateStoreField), and invocation IDs + // are not used to drive any subsequent invoke — they are emitted purely + // for trace correlation. if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" { fmt.Printf("Invocation: %s\n", invID) - if agentKey != "" && rc.azdClient != nil { - saveContextValue(ctx, rc.azdClient, agentKey, invID, "invocations") - } } captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, "Session: ") From 3d560e0430349b41653255a8a5ff236614f00907 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Wed, 6 May 2026 01:44:30 +0530 Subject: [PATCH 08/11] ai agents: replace buildEphemeralAgentKey with buildAgentKey Per PR review (trangevi): unify ephemeral and project-mode key builders. buildAgentKey(ep, name, '', false) yields the same canonical shape used elsewhere '/agents//versions/latest/remote' and inherits the segment validation logic for free. - Remove buildEphemeralAgentKey helper (config_store.go) - Update sole call site in invocations setup (invoke.go) - Drop now-redundant ephemeral-specific tests; URL-variant stability remains covered by TestNormalizeEndpoint_StripScheme and TestBuildRemoteAgentKeyFromEndpoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/config_store.go | 12 ----- .../internal/cmd/config_store_test.go | 46 ------------------- .../azure.ai.agents/internal/cmd/invoke.go | 2 +- 3 files changed, 1 insertion(+), 59 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go index 85ae6d858f2..f65b9a81aca 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go @@ -244,18 +244,6 @@ func buildRemoteAgentKeyFromEndpoint(agentEndpoint string) string { return normalizeEndpoint(agentEndpoint) + "/remote" } -// buildEphemeralAgentKey constructs a stable persistence key for --agent-endpoint -// invokes. It is derived from the parsed endpoint components rather than the raw -// URL so that variants of the same logical endpoint (with/without ?api-version, -// trailing slash, fragment, host casing) collapse to the same key. -// -// The "/ephemeral" suffix is intentionally distinct from project-mode "/remote" -// keys: the two have different scopes (ephemeral is anchored at endpoint+name -// only and never embeds a deploy version/api-version). -func buildEphemeralAgentKey(projectEndpoint, agentName string) string { - return fmt.Sprintf("%s/agents/%s/ephemeral", normalizeEndpoint(projectEndpoint), agentName) -} - // buildLocalAgentKey constructs a key for local mode. // projectPath is used to disambiguate across different projects using the same port. func buildLocalAgentKey(port int, agentName, version, projectPath string) string { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go index 747faa1ce16..27ce0ce9b97 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go @@ -117,52 +117,6 @@ func TestBuildRemoteAgentKeyFromEndpoint_EquivalentToBuildAgentKey(t *testing.T) require.Equal(t, fromParts, fromEndpoint) } -// TestBuildEphemeralAgentKey_StableAcrossURLVariants ensures that all URL -// shapes that the user could plausibly pass to --agent-endpoint (canonical, -// with/without api-version, trailing slash, fragment, mixed case host) -// collapse to the same persistence key. Cross-variant drift would silently -// fragment session/conversation state on every invoke. -func TestBuildEphemeralAgentKey_StableAcrossURLVariants(t *testing.T) { - t.Parallel() - - const ( - projectEndpoint = "https://acct.services.ai.azure.com/api/projects/proj" - agentName = "my-agent" - ) - want := "acct.services.ai.azure.com/api/projects/proj/agents/my-agent/ephemeral" - - tests := []struct { - name string - endpoint string - }{ - {"canonical", projectEndpoint}, - {"trailing slash", projectEndpoint + "/"}, - {"mixed case host", "https://Acct.Services.AI.Azure.Com/api/projects/proj"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := buildEphemeralAgentKey(tt.endpoint, agentName) - assert.Equal(t, want, got) - }) - } -} - -// TestBuildEphemeralAgentKey_DistinctFromRemote guards the contract that -// ephemeral keys never collide with project-mode keys for the same endpoint. -func TestBuildEphemeralAgentKey_DistinctFromRemote(t *testing.T) { - t.Parallel() - - const ( - projectEndpoint = "https://acct.services.ai.azure.com/api/projects/proj" - agentName = "my-agent" - ) - ephemeral := buildEphemeralAgentKey(projectEndpoint, agentName) - remote := buildAgentKey(projectEndpoint, agentName, "1", false) - assert.NotEqual(t, ephemeral, remote) -} - func TestNormalizeEndpoint_StripScheme(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 642e938e06c..63db7fe12a1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -456,7 +456,7 @@ func (a *InvokeAction) resolveRemoteContext(ctx context.Context) (*remoteContext if a.endpoint.APIVersion != "" { rc.apiVersion = a.endpoint.APIVersion } - rc.agentKey = buildEphemeralAgentKey(a.endpoint.ProjectEndpoint, a.endpoint.AgentName) + rc.agentKey = buildAgentKey(a.endpoint.ProjectEndpoint, a.endpoint.AgentName, "", false) // Best-effort attach to the parent azd daemon so session/conversation IDs // persist across invokes via global UserConfig. When running the extension // binary directly (standalone), this fails and we proceed without persistence. From 2b95eccd9980d4fdfef2cbae23d9d1053e9b49ea Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Wed, 6 May 2026 01:57:36 +0530 Subject: [PATCH 09/11] ai agents: fix --agent-endpoint help-text example to use responses URL Per PR review (trangevi): the example used the /protocols/invocations URL paired with a plain "Hello!" body, but most invocations samples expect a JSON request body. Switch the example to the responses-protocol URL (/protocols/openai/responses) which matches the plain-string body shape and aligns with the other "Hello!" examples in this help block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 63db7fe12a1..21c99327b06 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -93,7 +93,7 @@ session automatically. Pass --new-session to force a reset.`, # Invoke a deployed agent from any directory using the endpoint URL shown by 'azd ai agent show' azd ai agent invoke \ - --agent-endpoint https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations?api-version=2025-11-15-preview \ + --agent-endpoint https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/openai/responses?api-version=2025-11-15-preview \ "Hello!"`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { From a36082542b9c28415ec9540826f0cd3e48ca3381 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Wed, 6 May 2026 02:09:17 +0530 Subject: [PATCH 10/11] ai agents: remove warnIneffectiveResetFlags (dead in normal use) The warning only fired when running the extension binary standalone with --agent-endpoint and a reset flag (no parent azd daemon). End-user invokes always go through the host and persist via gRPC, so the warn was effectively dead for the supported flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/invoke.go | 27 ------------------- 1 file changed, 27 deletions(-) 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 21c99327b06..6786abaeed3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -224,24 +224,6 @@ func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { return nil } -// warnIneffectiveResetFlags prints to stderr when --new-session / -// --new-conversation were requested but persistence is not active (standalone -// mode without a parent azd daemon). The flags only have an effect when the -// extension can read/write the stored IDs, so silently accepting them would be -// misleading. We use stderr (not the std logger) because the extension -// silences `log` output unless debug mode is enabled. -func warnIneffectiveResetFlags(flags *invokeFlags) { - if flags.newSession { - fmt.Fprintln(os.Stderr, - "warning: --new-session has no effect without a parent azd daemon (session ID is not persisted)") - } - if flags.newConversation { - fmt.Fprintln(os.Stderr, - "warning: --new-conversation has no effect without a parent azd daemon "+ - "(conversation ID is not persisted)") - } -} - func (a *InvokeAction) Run(ctx context.Context) error { protocol, err := a.resolveProtocol(ctx) if err != nil { @@ -542,16 +524,10 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { return err } - // agentKey is set by resolveRemoteContext when persistence is applicable. - // In ephemeral mode without a parent azd daemon we still set it for clarity, - // but the helpers below early-return when azdClient is nil. agentKey := rc.agentKey if agentKey == "" && rc.azdClient != nil { log.Printf("warning: agent endpoint not available, session state will not be persisted") } - if a.endpoint != nil && rc.azdClient == nil { - warnIneffectiveResetFlags(a.flags) - } // Acquire the bearer token after body validation so a local input error // (e.g., unreadable --input-file) does not pay an unnecessary auth round-trip @@ -747,9 +723,6 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { if agentKey == "" && rc.azdClient != nil { log.Printf("warning: agent endpoint not available, session state will not be persisted") } - if a.endpoint != nil && rc.azdClient == nil { - warnIneffectiveResetFlags(a.flags) - } if a.flags.newConversation { fmt.Fprintln(os.Stderr, From 20d23e48dce74e0b66ab474b1f6ba71ca5659c67 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Wed, 6 May 2026 13:42:27 +0530 Subject: [PATCH 11/11] ai agents: address wbreza PR review nits + add resolveRemoteContext test - Rename url locals to respURL/convURL to avoid shadowing the net/url package import (invoke.go: responsesRemote and createConversation). - Add t.Parallel() to the 4 functions in agent_endpoint_test.go. - Add TestResolveRemoteContext_EphemeralMode covering the api-version default fallback (when URL omits ?api-version=) and explicit override, plus name/projectEndpoint/agentKey propagation. Pins the existing safe behavior end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/agent_endpoint_test.go | 74 +++++++++++++++++++ .../azure.ai.agents/internal/cmd/invoke.go | 16 ++-- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go index cabb033f43e..7dd027ae3a7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -11,6 +11,7 @@ import ( ) func TestParseAgentEndpoint(t *testing.T) { + t.Parallel() tests := []struct { name string raw string @@ -162,6 +163,7 @@ func TestParseAgentEndpoint(t *testing.T) { // these inputs would previously have been accepted locally and only failed // later as 404s on the wire. func TestParseAgentEndpoint_RejectsInvalidAgentNames(t *testing.T) { + t.Parallel() cases := []string{ // underscore — disallowed by the canonical validator "agent_v2", @@ -187,6 +189,7 @@ func TestParseAgentEndpoint_RejectsInvalidAgentNames(t *testing.T) { // TestBuildResponsesURL verifies that the responses URL builder uses the parsed // api-version (rather than the default fallback) and URL-encodes it. func TestBuildResponsesURL(t *testing.T) { + t.Parallel() parsed, err := parseAgentEndpoint( "https://acct.services.ai.azure.com/api/projects/proj/agents/echo/endpoint/protocols/openai/responses?api-version=2025-11-15-preview", ) @@ -209,6 +212,7 @@ func TestBuildResponsesURL(t *testing.T) { // TestBuildInvocationsURL verifies that the invocations URL builder propagates // the parsed api-version, URL-encodes it, and URL-encodes any session id. func TestBuildInvocationsURL(t *testing.T) { + t.Parallel() parsed, err := parseAgentEndpoint( "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations?api-version=2025-11-15-preview", ) @@ -238,3 +242,73 @@ func TestBuildInvocationsURL(t *testing.T) { } }) } + +// TestResolveRemoteContext_EphemeralMode exercises the ephemeral branch of +// resolveRemoteContext (--agent-endpoint path). It pins the api-version +// fallback (default applied when the URL omits the parameter) and the +// override (parsed value used when present), plus verifies that name, +// projectEndpoint, and agentKey are populated from the parsed endpoint. +// +// The project-mode branch is intentionally not covered here: it depends on +// the azd gRPC client and is exercised end-to-end by the functional/live +// tests in this PR's verification. +func TestResolveRemoteContext_EphemeralMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantAPIVersion string + wantName string + wantProject string + }{ + { + name: "api-version omitted falls back to default", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/" + + "hello/endpoint/protocols/openai/responses", + wantAPIVersion: DefaultAgentAPIVersion, + wantName: "hello", + wantProject: "https://acct.services.ai.azure.com/api/projects/proj", + }, + { + name: "explicit api-version overrides the default", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/" + + "hello/endpoint/protocols/invocations?api-version=2025-09-01-preview", + wantAPIVersion: "2025-09-01-preview", + wantName: "hello", + wantProject: "https://acct.services.ai.azure.com/api/projects/proj", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + parsed, err := parseAgentEndpoint(tc.raw) + if err != nil { + t.Fatalf("parseAgentEndpoint: %v", err) + } + a := &InvokeAction{flags: &invokeFlags{}, endpoint: parsed} + + rc, err := a.resolveRemoteContext(t.Context()) + if err != nil { + t.Fatalf("resolveRemoteContext: %v", err) + } + if rc.azdClient != nil { + defer rc.azdClient.Close() + } + + if rc.apiVersion != tc.wantAPIVersion { + t.Errorf("apiVersion = %q, want %q", rc.apiVersion, tc.wantAPIVersion) + } + if rc.name != tc.wantName { + t.Errorf("name = %q, want %q", rc.name, tc.wantName) + } + if rc.projectEndpoint != tc.wantProject { + t.Errorf("projectEndpoint = %q, want %q", rc.projectEndpoint, tc.wantProject) + } + if rc.agentKey == "" { + t.Errorf("agentKey is empty; should be populated for ephemeral persistence") + } + }) + } +} 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 6786abaeed3..9a35ee535f9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -601,8 +601,8 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { return fmt.Errorf("failed to marshal request: %w", err) } - url := buildResponsesURL(rc.projectEndpoint, rc.name, rc.apiVersion) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + respURL := buildResponsesURL(rc.projectEndpoint, rc.name, rc.apiVersion) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, respURL, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } @@ -613,7 +613,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { //nolint:gosec // G704: URL is built from a validated Foundry endpoint (env or --agent-endpoint) resp, err := client.Do(req) if err != nil { - return fmt.Errorf("POST %s failed: %w", url, err) + return fmt.Errorf("POST %s failed: %w", respURL, err) } defer resp.Body.Close() @@ -626,7 +626,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("POST %s failed with HTTP %d: %s\n%s", url, resp.StatusCode, resp.Status, string(respBody)) + return fmt.Errorf("POST %s failed with HTTP %d: %s\n%s", respURL, resp.StatusCode, resp.Status, string(respBody)) } // Parse SSE stream for agent output @@ -1089,11 +1089,11 @@ func handleInvocationLRO( // createConversation creates a new Foundry conversation for multi-turn memory. func createConversation(ctx context.Context, projectEndpoint, agentName, bearerToken string) (string, error) { - url := fmt.Sprintf( + convURL := fmt.Sprintf( "%s/agents/%s/endpoint/protocols/openai/conversations?api-version=%s", projectEndpoint, agentName, ConversationsAPIVersion, ) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}"))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, convURL, bytes.NewReader([]byte("{}"))) if err != nil { return "", err } @@ -1103,13 +1103,13 @@ func createConversation(ctx context.Context, projectEndpoint, agentName, bearerT client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) //nolint:gosec // G704: endpoint is resolved from azd environment configuration if err != nil { - return "", fmt.Errorf("POST %s failed: %w", url, err) + return "", fmt.Errorf("POST %s failed: %w", convURL, err) } defer resp.Body.Close() if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("POST %s failed with HTTP %d: %s\n%s", url, resp.StatusCode, resp.Status, string(respBody)) + return "", fmt.Errorf("POST %s failed with HTTP %d: %s\n%s", convURL, resp.StatusCode, resp.Status, string(respBody)) } body, err := io.ReadAll(resp.Body)