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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cli/azd/extensions/azure.ai.agents/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Release History

## Unreleased

- Add a `--call-id` flag to `azd ai agent invoke` that sends the `x-agent-foundry-call-id` header on `--local` invocations only. It is ignored for remote Foundry requests.
- Replace the per-command Foundry isolation-key flags (`--user-isolation-key`, `--chat-isolation-key`, and the session-ownership `--isolation-key`) with a single `--user-identity` flag. The value is sent as the `x-agent-user-id` header for `--local` invocations and as `x-ms-user-identity` for all remote Foundry requests. This is a breaking change with no backward-compatible flag retention.

## 0.1.41-preview (2026-06-19)

- [[#8731]](https://github.com/Azure/azure-dev/pull/8731) Improve the post-deploy `Next:` guidance with a stacked layout that puts each command on its own line above its description, adds a blank line between suggestions, and highlights `azd` commands. The new layout applies across deploy, `azd ai agent show`, `init`, and `doctor`. Thanks @therealjohn for the contribution!
Expand Down
8 changes: 4 additions & 4 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (

// filesFlags holds the common flags shared by all file subcommands.
type filesFlags struct {
isolationHeaderFlags
userIdentityFlags
agentName string // optional: agent name (matches azure.yaml service name)
session string // optional: explicit session ID override
}
Expand All @@ -41,8 +41,8 @@ azd environment. Use --agent-name to select a specific agent when the project
has multiple azure.ai.agent services. The session ID is automatically resolved
from the last invoke session, or can be overridden with --session-id.

For agents configured with header-based isolation, pass --user-isolation-key
and --chat-isolation-key on each file operation.`,
For agents configured with header-based isolation, pass --user-identity
on each file operation.`,
}

cmd.AddCommand(newFilesUploadCommand(extCtx))
Expand All @@ -59,7 +59,7 @@ and --chat-isolation-key on each file operation.`,
func addFilesFlags(cmd *cobra.Command, flags *filesFlags) {
cmd.Flags().StringVarP(&flags.agentName, "agent-name", "n", "", "Agent name (matches azure.yaml service name; auto-detected when only one exists)")
cmd.Flags().StringVarP(&flags.session, "session-id", "s", "", "Session ID override (defaults to last invoke session)")
addIsolationHeaderFlags(cmd, &flags.isolationHeaderFlags)
addUserIdentityFlag(cmd, &flags.userIdentityFlags)
}

// filesContext holds the resolved agent context and session for file operations.
Expand Down
20 changes: 8 additions & 12 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ func TestFilesUploadCommand_HasFlags(t *testing.T) {
"target-path",
"agent-name",
"session-id",
"user-isolation-key",
"chat-isolation-key",
"user-identity",
} {
f := cmd.Flags().Lookup(name)
require.NotNil(t, f, "expected flag %q", name)
Expand All @@ -72,8 +71,7 @@ func TestFilesDownloadCommand_HasFlags(t *testing.T) {
"target-path",
"agent-name",
"session-id",
"user-isolation-key",
"chat-isolation-key",
"user-identity",
} {
f := cmd.Flags().Lookup(name)
require.NotNil(t, f, "expected flag %q", name)
Expand All @@ -86,10 +84,10 @@ func TestFilesListCommand_DefaultOutputFormat(t *testing.T) {
assertOutputFlagOptions(t, cmd, "json", []string{"json", "table"})
}

func TestFilesListCommand_HasIsolationFlags(t *testing.T) {
func TestFilesListCommand_HasUserIdentityFlag(t *testing.T) {
cmd := newFilesListCommand(nil)

for _, name := range []string{"user-isolation-key", "chat-isolation-key"} {
for _, name := range []string{"user-identity"} {
f := cmd.Flags().Lookup(name)
require.NotNil(t, f, "expected flag %q", name)
assert.Equal(t, "", f.DefValue)
Expand Down Expand Up @@ -121,8 +119,7 @@ func TestFilesDeleteCommand_HasFlags(t *testing.T) {
"recursive",
"agent-name",
"session-id",
"user-isolation-key",
"chat-isolation-key",
"user-identity",
} {
f := cmd.Flags().Lookup(name)
require.NotNil(t, f, "expected flag %q", name)
Expand All @@ -149,19 +146,18 @@ func TestFilesMkdirCommand_HasFlags(t *testing.T) {
"dir",
"agent-name",
"session-id",
"user-isolation-key",
"chat-isolation-key",
"user-identity",
} {
f := cmd.Flags().Lookup(name)
require.NotNil(t, f, "expected flag %q", name)
assert.Equal(t, "", f.DefValue)
}
}

func TestFilesStatCommand_HasIsolationFlags(t *testing.T) {
func TestFilesStatCommand_HasUserIdentityFlag(t *testing.T) {
cmd := newFilesStatCommand(nil)

for _, name := range []string{"user-isolation-key", "chat-isolation-key"} {
for _, name := range []string{"user-identity"} {
f := cmd.Flags().Lookup(name)
require.NotNil(t, f, "expected flag %q", name)
assert.Equal(t, "", f.DefValue)
Expand Down
29 changes: 23 additions & 6 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (
)

type invokeFlags struct {
isolationHeaderFlags
userIdentityFlags
message string
inputFile string
local bool
Expand All @@ -44,6 +44,7 @@ type invokeFlags struct {
agentEndpoint string
version string
outputFmt string
callID string
}

// outputRaw is the sentinel value of the inherited --output flag that selects
Expand Down Expand Up @@ -94,8 +95,13 @@ session automatically. Pass --new-session to force a reset.
Use --version to invoke a specific deployed agent version. When provided,
azd creates or reuses a hosted agent session backed by that version.

For agents configured with header-based isolation, pass --user-isolation-key
and --chat-isolation-key on each remote invoke.
For agents configured with header-based isolation, pass --user-identity
on each invoke. Locally it is sent as the x-agent-user-id header; for
remote invokes it is sent as the x-ms-user-identity header.

Use --call-id to send a call ID with a local invoke. It is sent as the
x-agent-foundry-call-id header and applies only to local invocations; it is
ignored for remote requests.

Use --output raw (or -o raw) to dump the unmodified server response (status
line, headers, and body verbatim) to stdout. Useful for debugging server
Expand Down Expand Up @@ -227,7 +233,14 @@ suppressed in raw mode.`,
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)")
addIsolationHeaderFlags(cmd, &flags.isolationHeaderFlags)
addUserIdentityFlag(cmd, &flags.userIdentityFlags)
cmd.Flags().StringVar(
&flags.callID,
"call-id",
"",
"Call ID header value (sent as "+agent_api.AgentFoundryCallIDHeader+" for local invocations only; "+
"ignored for remote requests)",
)
cmd.Flags().StringVar(
&flags.agentEndpoint,
"agent-endpoint",
Expand Down Expand Up @@ -584,6 +597,8 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
applyLocalUserIdentityHeader(req, &a.flags.userIdentityFlags)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] applyLocalUserIdentityHeader is new here (local paths didn't set user-identity headers before this PR). The --call-id behavior has a dedicated test (invoke_call_id_test.go), but no test verifies that x-agent-user-id is sent on local requests when --user-identity is set. Consider extending the call-id test to also assert the user-identity header, or adding a sibling test file for it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot work on it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 9e497b5: extended local invoke tests to assert x-agent-user-id is sent when --user-identity is set (for both local protocols), and added a request-sent assertion so cases can’t pass before sending a request.

applyLocalCallIDHeader(req, a.flags.callID)
if raw {
// Disable Go's transparent gzip handling so the dumped headers and
// body match what the server actually sent on the wire.
Expand Down Expand Up @@ -983,7 +998,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+rc.bearerToken)
req.Header.Set("Foundry-Features", "HostedAgents=V1Preview")
applyIsolationHeaders(req, &a.flags.isolationHeaderFlags)
applyRemoteUserIdentityHeader(req, &a.flags.userIdentityFlags)
if raw {
// Disable Go's transparent gzip handling so the dumped headers and
// body match what the server actually sent on the wire.
Expand Down Expand Up @@ -1109,6 +1124,8 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", contentTypeForBody(body))
applyLocalUserIdentityHeader(req, &a.flags.userIdentityFlags)
applyLocalCallIDHeader(req, a.flags.callID)
if raw {
// Disable Go's transparent gzip handling so the dumped headers and
// body match what the server actually sent on the wire.
Expand Down Expand Up @@ -1219,7 +1236,7 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error {
req.Header.Set("Content-Type", contentTypeForBody(body))
req.Header.Set("Authorization", "Bearer "+rc.bearerToken)
req.Header.Set("Foundry-Features", "HostedAgents=V1Preview")
applyIsolationHeaders(req, &a.flags.isolationHeaderFlags)
applyRemoteUserIdentityHeader(req, &a.flags.userIdentityFlags)
if raw {
// Disable Go's transparent gzip handling so the dumped headers and
// body match what the server actually sent on the wire.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"azureaiagent/internal/pkg/agents/agent_api"
)

// TestLocalInvoke_CallIDHeader verifies that --call-id is sent as the
// x-agent-foundry-call-id header on local invocations for both protocols.
func TestLocalInvoke_CallIDHeader(t *testing.T) {
okBody, _ := json.Marshal(map[string]any{
"output": []any{map[string]any{"content": []any{map[string]any{"type": "output_text", "text": "hi"}}}},
})

cases := []struct {
name string
protocol string
callID string
userIdentity string
wantCallID bool
wantUserID bool
}{
{"responses_with_call_id", "responses", "call-123", "", true, false},
{"responses_without_call_id", "responses", "", "", false, false},
{"invocations_with_call_id", "invocations", "call-456", "", true, false},
{"invocations_without_call_id", "invocations", "", "", false, false},
{"responses_with_user_identity", "responses", "", "user-123", false, true},
{"invocations_with_user_identity", "invocations", "", "user-456", false, true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var gotHeader string
var headerPresent bool
var gotUserHeader string
var userHeaderPresent bool
var requestCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/openapi") {
w.WriteHeader(404)
return
}
requestCount++
gotHeader = r.Header.Get(agent_api.AgentFoundryCallIDHeader)
_, headerPresent = r.Header[http.CanonicalHeaderKey(agent_api.AgentFoundryCallIDHeader)]
gotUserHeader = r.Header.Get(agent_api.AgentUserIDHeader)
_, userHeaderPresent = r.Header[http.CanonicalHeaderKey(agent_api.AgentUserIDHeader)]
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprint(w, string(okBody))
}))
defer srv.Close()

action := &InvokeAction{
flags: &invokeFlags{
message: "hi",
port: testPort(t, srv.URL),
local: true,
protocol: tc.protocol,
callID: tc.callID,
userIdentityFlags: userIdentityFlags{
userIdentity: tc.userIdentity,
},
},
noPrompt: true,
}

var err error
withCapturedStdout(t, func() {
if tc.protocol == "responses" {
err = action.responsesLocal(t.Context())
} else {
err = action.invocationsLocal(t.Context())
}
})
Comment thread
Copilot marked this conversation as resolved.
if err != nil {
t.Fatalf("local invoke failed: %v", err)
}
if requestCount == 0 {
t.Fatal("expected at least one request to local server")
}

assertLocalHeader(
t,
agent_api.AgentFoundryCallIDHeader,
gotHeader,
tc.callID,
headerPresent,
tc.wantCallID,
)
assertLocalHeader(
t,
agent_api.AgentUserIDHeader,
gotUserHeader,
tc.userIdentity,
userHeaderPresent,
tc.wantUserID,
)
})
}
}

func assertLocalHeader(t *testing.T, header, got, want string, present bool, shouldBeSet bool) {
t.Helper()

if shouldBeSet {
if got != want {
t.Errorf("header %s = %q, want %q", header, got, want)
}
return
}

if present {
t.Errorf("header %s should not be set, got %q", header, got)
}
}
Loading
Loading