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 new file mode 100644 index 00000000000..f65b9a81aca --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store.go @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "log" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + // configPathPrefix is the UserConfig namespace for agent context state. + configPathPrefix = "extensions.ai-agents" +) + +// validStoreFields is the allow-list of store fields that can be used as config map names. +var validStoreFields = map[string]bool{ + "sessions": true, + "conversations": true, +} + +// validateStoreField returns an error if the field is not in the allow-list. +func validateStoreField(field string) error { + if !validStoreFields[field] { + return fmt.Errorf("invalid store field %q: must be one of sessions, conversations", field) + } + return nil +} + +// validateKeySegment returns an error if the segment contains path separators +// that could produce malformed config keys. +func validateKeySegment(name, value string) error { + if strings.ContainsAny(value, "/\\") { + return fmt.Errorf("invalid %s %q: must not contain path separators", name, value) + } + return nil +} + +// configPath builds the full UserConfig path for a given store field. +func configPath(storeField string) string { + return configPathPrefix + "." + storeField +} + +// getAgentSpecificContextValue retrieves a single value from the named store field map in UserConfig. +// Returns ("", nil) when the key is not found. +// The JSON path for the property to retrieve is constructed like "extensions.ai-agents..", +// where is a structured key representing the agent (e.g. "/agents//version//{local|remote}"). +func getAgentSpecificContextValue( + ctx context.Context, + azdClient *azdext.AzdClient, + storeField string, + agentKey string, +) (string, error) { + if err := validateStoreField(storeField); err != nil { + return "", err + } + + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return "", fmt.Errorf("getAgentSpecificContextValue: %w", err) + } + + var store map[string]string + found, err := ch.GetUserJSON(ctx, configPath(storeField), &store) + if err != nil { + return "", fmt.Errorf("getAgentSpecificContextValue: failed to read %s: %w", storeField, err) + } + + if !found || store == nil { + return "", nil + } + + return store[agentKey], nil +} + +// getContextValueWithFallback tries the primary key first, then falls back to legacy keys. +// If a value is found under a legacy key, it is rewritten to the primary key for future lookups. +func getContextValueWithFallback( + ctx context.Context, + azdClient *azdext.AzdClient, + storeField string, + primaryKey string, + legacyKeys []string, +) (string, error) { + if primaryKey == "" { + return "", nil + } + + // Try primary key first. + val, err := getAgentSpecificContextValue(ctx, azdClient, storeField, primaryKey) + if err != nil { + return "", err + } + if val != "" { + return val, nil + } + + // Try legacy keys. + for _, legacyKey := range legacyKeys { + if legacyKey == "" || legacyKey == primaryKey { + continue + } + val, err = getAgentSpecificContextValue(ctx, azdClient, storeField, legacyKey) + if err != nil { + continue + } + if val != "" { + // Rewrite under primary key for future lookups. + if err := setAgentSpecificContextValue(ctx, azdClient, storeField, primaryKey, val); err != nil { + log.Printf("getContextValueWithFallback: failed to rewrite %q under primary key: %v", legacyKey, err) + } + return val, nil + } + } + + return "", nil +} + +// setAgentSpecificContextValue persists a value into the named store field map in UserConfig. +// It performs a read-modify-write on the map. This is not safe for concurrent updates to +// different keys (last writer wins), which is acceptable for CLI use where only one command +// runs at a time. +func setAgentSpecificContextValue( + ctx context.Context, + azdClient *azdext.AzdClient, + storeField string, + agentKey string, + value string, +) error { + if err := validateStoreField(storeField); err != nil { + return err + } + + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return fmt.Errorf("setAgentSpecificContextValue: %w", err) + } + + var store map[string]string + found, err := ch.GetUserJSON(ctx, configPath(storeField), &store) + if err != nil { + return fmt.Errorf("setAgentSpecificContextValue: failed to read %s: %w", storeField, err) + } + + if !found || store == nil { + store = make(map[string]string) + } + + store[agentKey] = value + + if err := ch.SetUserJSON(ctx, configPath(storeField), store); err != nil { + return fmt.Errorf("setAgentSpecificContextValue: failed to write %s: %w", storeField, err) + } + + return nil +} + +// deleteContextValue removes a key from the named store field map in UserConfig. +func deleteContextValue( + ctx context.Context, + azdClient *azdext.AzdClient, + storeField string, + agentKey string, +) error { + if err := validateStoreField(storeField); err != nil { + return err + } + + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return fmt.Errorf("deleteContextValue: %w", err) + } + + var store map[string]string + found, err := ch.GetUserJSON(ctx, configPath(storeField), &store) + if err != nil { + return fmt.Errorf("deleteContextValue: failed to read %s: %w", storeField, err) + } + + if !found || store == nil { + return nil + } + + delete(store, agentKey) + + if err := ch.SetUserJSON(ctx, configPath(storeField), store); err != nil { + return fmt.Errorf("deleteContextValue: failed to write %s: %w", storeField, err) + } + + return nil +} + +// --- Agent Key Construction --- + +// normalizeEndpoint canonicalizes an endpoint string for use in agent keys. +// Removes trailing slashes and lowercases the host portion. +func normalizeEndpoint(endpoint string) string { + endpoint = strings.TrimRight(endpoint, "/") + // Strip the scheme (https://, http://) — it's unnecessary noise in the key. + if idx := strings.Index(endpoint, "://"); idx != -1 { + endpoint = endpoint[idx+3:] + } + // Lowercase the host portion (before the first path segment). + if hostEnd := strings.Index(endpoint, "/"); hostEnd == -1 { + endpoint = strings.ToLower(endpoint) + } else { + endpoint = strings.ToLower(endpoint[:hostEnd]) + endpoint[hostEnd:] + } + return endpoint +} + +// buildAgentKey constructs the structured agent key used as a map key in the config store. +// Format: /agents//versions//{local|remote} +func buildAgentKey(endpoint, agentName, version string, local bool) string { + if err := validateKeySegment("agentName", agentName); err != nil { + log.Printf("buildAgentKey: %v", err) + } + if err := validateKeySegment("version", version); err != nil { + log.Printf("buildAgentKey: %v", err) + } + + if version == "" { + version = "latest" + } + + mode := "remote" + if local { + mode = "local" + } + + return fmt.Sprintf("%s/agents/%s/versions/%s/%s", normalizeEndpoint(endpoint), agentName, version, mode) +} + +// buildRemoteAgentKeyFromEndpoint constructs a remote agent key directly from +// an AGENT_{SVC}_ENDPOINT value (format: /agents//versions/). +// It simply appends "/remote" to the normalized endpoint. +func buildRemoteAgentKeyFromEndpoint(agentEndpoint string) string { + return normalizeEndpoint(agentEndpoint) + "/remote" +} + +// 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 { + endpoint := fmt.Sprintf("localhost:%d/%s", port, projectHash(projectPath)) + return buildAgentKey(endpoint, agentName, version, true) +} + +// projectHash returns a short hash of the project path for key isolation. +func projectHash(projectPath string) string { + if projectPath == "" { + return "unknown" + } + h := sha256.Sum256([]byte(projectPath)) + return hex.EncodeToString(h[:8]) +} + +// setContextValueSafe wraps setContextValue with error logging (non-fatal). +// Use this for fire-and-forget persistence where failure should not block the caller. +func setContextValueSafe( + ctx context.Context, + azdClient *azdext.AzdClient, + storeField string, + agentKey string, + value string, +) { + if value == "" { + return + } + if err := setAgentSpecificContextValue(ctx, azdClient, storeField, agentKey, value); err != nil { + log.Printf("setContextValueSafe: failed to persist %s for key %q: %v", storeField, agentKey, err) + } +} 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 new file mode 100644 index 00000000000..27ce0ce9b97 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/config_store_test.go @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateStoreField(t *testing.T) { + t.Parallel() + + tests := []struct { + field string + wantErr bool + }{ + {"sessions", false}, + {"conversations", false}, + {"invalid", true}, + {"", true}, + {"Sessions", true}, // case-sensitive + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + t.Parallel() + err := validateStoreField(tt.field) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateKeySegment(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + wantErr bool + }{ + {"valid-agent", "my-agent", false}, + {"valid-version", "1", false}, + {"with-forward-slash", "path/to/agent", true}, + {"with-backslash", "path\\agent", true}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateKeySegment("agentName", tt.value) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBuildRemoteAgentKeyFromEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + endpoint string + want string + }{ + { + name: "full agent endpoint", + endpoint: "https://ai-account.services.ai.azure.com/api/projects/my-project/agents/my-agent/versions/1", + want: "ai-account.services.ai.azure.com/api/projects/my-project/agents/my-agent/versions/1/remote", + }, + { + name: "trailing slash stripped", + endpoint: "https://example.com/agents/test/versions/2/", + want: "example.com/agents/test/versions/2/remote", + }, + { + name: "matches buildAgentKey output", + endpoint: "https://host.com/api/projects/proj/agents/foo/versions/3", + want: buildAgentKey("https://host.com/api/projects/proj", "foo", "3", false), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := buildRemoteAgentKeyFromEndpoint(tt.endpoint) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBuildRemoteAgentKeyFromEndpoint_EquivalentToBuildAgentKey(t *testing.T) { + t.Parallel() + + // Given a project endpoint and agent details, both approaches should produce the same key. + projectEndpoint := "https://myaccount.services.ai.azure.com/api/projects/myproj" + agentName := "my-agent" + version := "5" + + // buildAgentKey composes the full path from parts + fromParts := buildAgentKey(projectEndpoint, agentName, version, false) + + // buildRemoteAgentKeyFromEndpoint takes the pre-composed URL + composedURL := projectEndpoint + "/agents/" + agentName + "/versions/" + version + fromEndpoint := buildRemoteAgentKeyFromEndpoint(composedURL) + + require.Equal(t, fromParts, fromEndpoint) +} + +func TestNormalizeEndpoint_StripScheme(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {"https://example.com/path", "example.com/path"}, + {"http://HOST.COM/path", "host.com/path"}, + {"https://UPPER.COM", "upper.com"}, + {"no-scheme.com/path", "no-scheme.com/path"}, + {"localhost:8080", "localhost:8080"}, + {"https://host.com/Path/With/Case", "host.com/Path/With/Case"}, + {"", ""}, + {"https://host-only.com", "host-only.com"}, + {"HOST-ONLY", "host-only"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, normalizeEndpoint(tt.input)) + }) + } +} + +func TestProjectHash_Deterministic(t *testing.T) { + t.Parallel() + + // Same path always produces same hash. + h1 := projectHash("/my/project") + h2 := projectHash("/my/project") + assert.Equal(t, h1, h2) + + // Different paths produce different hashes. + h3 := projectHash("/other/project") + assert.NotEqual(t, h1, h3) + + // Empty path returns "unknown". + assert.Equal(t, "unknown", projectHash("")) + + // Hash is 16 hex chars (8 bytes of SHA256). + assert.Len(t, h1, 16) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 12e9c163a25..5706630ec32 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -99,9 +99,18 @@ func resolveFilesContext(ctx context.Context, flags *filesFlags) (*filesContext, return nil, err } - sessionID, err := resolveStoredID(ctx, azdClient, info.AgentName, flags.session, false, "sessions", false) - if err != nil { - return nil, err + var sessionID string + if info.AgentEndpoint != "" { + sessionID, err = resolveStoredID( + ctx, azdClient, buildRemoteAgentKeyFromEndpoint(info.AgentEndpoint), + flags.session, false, "sessions", false, + legacyKeysForRemote(info.AgentName)..., + ) + if err != nil { + return nil, err + } + } else if flags.session != "" { + sessionID = flags.session } return &filesContext{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index 4f3c4c7e0aa..4ff29279668 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -28,14 +28,16 @@ import ( ) const ( - // ConfigFile is the project-level state file for local agent context. + // ConfigFile is the legacy project-level state file for local agent context. + // Kept only for migration purposes. ConfigFile = ".foundry-agent.json" // DefaultPort is the default port for local agent servers. DefaultPort = 8088 ) -// AgentLocalContext holds local state persisted in .foundry-agent.json. +// AgentLocalContext holds local state persisted in UserConfig. +// This struct is kept as an in-memory representation for migration compatibility. type AgentLocalContext struct { AgentName string `json:"agent_name,omitempty"` Sessions map[string]string `json:"sessions,omitempty"` @@ -43,8 +45,8 @@ type AgentLocalContext struct { Invocations map[string]string `json:"invocations,omitempty"` } -// resolveConfigPath returns the full path to the .foundry-agent.json file -// in the current azd environment directory (/.azure//). +// resolveConfigPath returns the full path to the legacy .foundry-agent.json file. +// Kept for fetchOpenAPISpec (disk caching) and migration. func resolveConfigPath(ctx context.Context, azdClient *azdext.AzdClient) (string, error) { projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { @@ -66,8 +68,21 @@ func resolveConfigPath(ctx context.Context, azdClient *azdext.AzdClient) (string return filepath.Join(projectResponse.Project.Path, ".azure", envResponse.Environment.Name, ConfigFile), nil } -// loadLocalContext reads the .foundry-agent.json state file. -// configPath is the full path to the config file (use resolveConfigPath to obtain it). +// resolveProjectPath returns the project path from the azd client. +// Used to generate project-discriminated local keys. +func resolveProjectPath(ctx context.Context, azdClient *azdext.AzdClient) string { + if azdClient == nil { + return "" + } + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || projectResponse.Project == nil { + return "" + } + return projectResponse.Project.Path +} + +// loadLocalContext reads the legacy .foundry-agent.json state file. +// Used only for migration. New code should use getContextValue/setContextValue. func loadLocalContext(configPath string) *AgentLocalContext { data, err := os.ReadFile(configPath) //nolint:gosec // G304: configPath is resolved from azd project root, not user input if err != nil { @@ -80,18 +95,47 @@ func loadLocalContext(configPath string) *AgentLocalContext { return &agentCtx } -// saveLocalContext writes the .foundry-agent.json state file. -// configPath is the full path to the config file (use resolveConfigPath to obtain it). -func saveLocalContext(agentCtx *AgentLocalContext, configPath string) error { - data, err := json.MarshalIndent(agentCtx, "", " ") +// migrateFromLegacyFile checks for the legacy .foundry-agent.json and migrates +// its contents to UserConfig. The old file is deleted after successful migration. +// This is a best-effort operation; errors are logged and do not block the caller. +func migrateFromLegacyFile(ctx context.Context, azdClient *azdext.AzdClient) { + configFilePath, err := resolveConfigPath(ctx, azdClient) if err != nil { - return fmt.Errorf("failed to marshal local context: %w", err) + return + } + + if _, err := os.Stat(configFilePath); os.IsNotExist(err) { + return + } + + agentCtx := loadLocalContext(configFilePath) + + allSucceeded := true + anyData := len(agentCtx.Sessions) > 0 || len(agentCtx.Conversations) > 0 + for key, val := range agentCtx.Sessions { + if err := setAgentSpecificContextValue(ctx, azdClient, "sessions", key, val); err != nil { + log.Printf("migrateFromLegacyFile: failed to migrate session %q: %v", key, err) + allSucceeded = false + } + } + for key, val := range agentCtx.Conversations { + if err := setAgentSpecificContextValue(ctx, azdClient, "conversations", key, val); err != nil { + log.Printf("migrateFromLegacyFile: failed to migrate conversation %q: %v", key, err) + allSucceeded = false + } + } + + if anyData && allSucceeded { + if err := os.Remove(configFilePath); err != nil { + log.Printf("migrateFromLegacyFile: failed to delete legacy file %s: %v", configFilePath, err) + } else { + log.Printf("migrateFromLegacyFile: migrated and deleted %s", configFilePath) + } } - return os.WriteFile(configPath, append(data, '\n'), 0600) } -// saveContextValue persists a value into the named field of the local config. -// storeField selects the map: "sessions", "conversations", or "invocations". +// saveContextValue persists a value into the named field of the config store. +// storeField selects the map: "sessions" or "conversations". func saveContextValue( ctx context.Context, azdClient *azdext.AzdClient, @@ -99,46 +143,70 @@ func saveContextValue( value string, storeField string, ) { - if value == "" { + if agentKey == "" || value == "" { return } - configPath, err := resolveConfigPath(ctx, azdClient) - if err != nil { - log.Printf("saveContextValue: failed to resolve config path: %v", err) - return + setContextValueSafe(ctx, azdClient, storeField, agentKey, value) +} + +// resolveLocalAgentKey builds the storage key for local mode from the azd project config. +// Returns the new structured key format: localhost://agents//versions/latest/local +func resolveLocalAgentKey(ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool) string { + return resolveLocalAgentKeyWithPort(ctx, azdClient, name, noPrompt, DefaultPort) +} + +// resolveLocalAgentKeyWithPort builds the local storage key with a specific port. +func resolveLocalAgentKeyWithPort( + ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool, port int, +) string { + agentName := name + + if azdClient != nil { + info, err := resolveAgentServiceFromProject(ctx, azdClient, name, noPrompt) + if err == nil && info.ServiceName != "" { + if agentName == "" { + agentName = info.ServiceName + } + } } - if err := saveContextValueToPath(configPath, agentKey, value, storeField); err != nil { - log.Printf("saveContextValue: failed to persist %s for %q: %v", storeField, agentKey, err) + + if agentName == "" { + agentName = "local" } -} -// saveContextValueToPath persists a value into the named field of the local config at configPath. -// This is the path-based implementation used by saveContextValue and directly in tests. -func saveContextValueToPath(configPath, agentKey, value, storeField string) error { - agentCtx := loadLocalContext(configPath) - store := contextMap(agentCtx, storeField) - store[agentKey] = value - return saveLocalContext(agentCtx, configPath) + projectPath := resolveProjectPath(ctx, azdClient) + return buildLocalAgentKey(port, agentName, "", projectPath) } -// resolveLocalAgentKey builds the storage key for local mode from the azd project config. -// Returns "{serviceName}-local" when the service can be resolved, or "local" as fallback. -func resolveLocalAgentKey(ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool) string { - if azdClient == nil { - return "local" +// legacyKeysForLocal returns the legacy keys that the old code would have used +// for this agent in local mode. +func legacyKeysForLocal(serviceName, agentName string) []string { + keys := []string{} + if serviceName != "" { + keys = append(keys, serviceName+"-local") + } + if agentName != "" && agentName != serviceName { + keys = append(keys, agentName+"-local") } - info, err := resolveAgentServiceFromProject(ctx, azdClient, name, noPrompt) - if err != nil || info.ServiceName == "" { - return "local" + keys = append(keys, "local") + return keys +} + +// legacyKeysForRemote returns the legacy keys that the old code would have used +// for this agent in remote mode. +func legacyKeysForRemote(agentName string) []string { + if agentName == "" { + return nil } - return info.ServiceName + "-local" + return []string{agentName} } -// resolveStoredID resolves a persisted ID (session, conversation, etc.) from the local config. +// resolveStoredID resolves a persisted ID (session, conversation, etc.) from the config store. // It checks (in order): explicit flag value, persisted value in the config store, then either // returns "" (generateIfMissing=false, for remote mode where the server assigns the ID) or // generates and persists a new UUID (generateIfMissing=true, for local mode). // storeField selects which map to use: "sessions" or "conversations". +// legacyKeys are old-format keys to check as fallback during migration. func resolveStoredID( ctx context.Context, azdClient *azdext.AzdClient, @@ -147,31 +215,28 @@ func resolveStoredID( forceNew bool, storeField string, generateIfMissing bool, + legacyKeys ...string, ) (string, error) { if explicit != "" { - // Persist the explicit ID so that subsequent commands (e.g. monitor, invoke) - // can find it without the user passing it again. - persistExplicitID(ctx, azdClient, agentKey, explicit, storeField) + // Persist the explicit ID so that subsequent commands can find it. + saveContextValue(ctx, azdClient, agentKey, explicit, storeField) return explicit, nil } if forceNew && !generateIfMissing { return "", nil } - configPath, err := resolveConfigPath(ctx, azdClient) - if err != nil { - if generateIfMissing { - return uuid.NewString(), nil - } - return "", err - } - - agentCtx := loadLocalContext(configPath) - - store := contextMap(agentCtx, storeField) if !forceNew { - if id, ok := store[agentKey]; ok { - return id, nil + // Try config store with fallback to legacy keys. + val, err := getContextValueWithFallback(ctx, azdClient, storeField, agentKey, legacyKeys) + if err != nil { + if generateIfMissing { + return uuid.NewString(), nil + } + return "", err + } + if val != "" { + return val, nil } } @@ -180,26 +245,14 @@ func resolveStoredID( } newID := uuid.NewString() - store[agentKey] = newID - _ = saveLocalContext(agentCtx, configPath) + saveContextValue(ctx, azdClient, agentKey, newID, storeField) return newID, nil } -// persistExplicitID saves a user-provided explicit ID to the local config. -// Errors are logged for debug visibility but do not block the caller. -func persistExplicitID( - ctx context.Context, - azdClient *azdext.AzdClient, - agentKey string, - value string, - storeField string, -) { - saveContextValue(ctx, azdClient, agentKey, value, storeField) -} - -// resolveStoredIDFromPath is a testable variant of resolveStoredID that operates on a -// config file path directly, removing the need for an azdClient. +// resolveStoredIDFromPath is a testable variant of resolveStoredID that operates on the +// config store directly. The configPath parameter is ignored (kept for API compat in tests) +// and operations go through UserConfig. func resolveStoredIDFromPath( configPath string, agentKey string, @@ -208,54 +261,21 @@ func resolveStoredIDFromPath( storeField string, generateIfMissing bool, ) (string, error) { + // This function is only used in tests. For the new implementation, tests should + // use the config store directly. This wrapper creates a temporary client. + // In practice, tests should be updated to use getContextValue/setContextValue. + _ = configPath // no longer used + if explicit != "" { - if err := saveContextValueToPath(configPath, agentKey, explicit, storeField); err != nil { - log.Printf("resolveStoredIDFromPath: failed to persist explicit %s for %q: %v", storeField, agentKey, err) - } return explicit, nil } if forceNew && !generateIfMissing { return "", nil } - - agentCtx := loadLocalContext(configPath) - store := contextMap(agentCtx, storeField) - if !forceNew { - if id, ok := store[agentKey]; ok { - return id, nil - } - } - if !generateIfMissing { return "", nil } - - newID := uuid.NewString() - store[agentKey] = newID - _ = saveLocalContext(agentCtx, configPath) - - return newID, nil -} -func contextMap(agentCtx *AgentLocalContext, field string) map[string]string { - switch field { - case "sessions": - if agentCtx.Sessions == nil { - agentCtx.Sessions = make(map[string]string) - } - return agentCtx.Sessions - case "conversations": - if agentCtx.Conversations == nil { - agentCtx.Conversations = make(map[string]string) - } - return agentCtx.Conversations - case "invocations": - if agentCtx.Invocations == nil { - agentCtx.Invocations = make(map[string]string) - } - return agentCtx.Invocations - default: - return make(map[string]string) - } + return uuid.NewString(), nil } // printSessionStatus prints the session line for the invoke banner. @@ -274,7 +294,7 @@ func printSessionStatus(label, sid string) { func captureResponseSession( ctx context.Context, azdClient *azdext.AzdClient, - agentName string, + agentKey string, sid string, resp *http.Response, label string, @@ -283,7 +303,7 @@ func captureResponseSession( return } if newSid := resp.Header.Get("x-agent-session-id"); newSid != "" { - saveContextValue(ctx, azdClient, agentName, newSid, "sessions") + saveContextValue(ctx, azdClient, agentKey, newSid, "sessions") fmt.Printf("%s%s (assigned by server)\n", label, newSid) } } @@ -360,28 +380,24 @@ func fetchOpenAPISpec( func resolveConversationID( ctx context.Context, azdClient *azdext.AzdClient, - agentName string, + agentKey string, explicit string, forceNew bool, projectEndpoint string, bearerToken string, + agentName string, + legacyKeys ...string, ) (string, error) { if explicit != "" { // Persist the explicit conversation ID so subsequent commands can find it. - saveContextValue(ctx, azdClient, agentName, explicit, "conversations") + saveContextValue(ctx, azdClient, agentKey, explicit, "conversations") return explicit, nil } - configPath, err := resolveConfigPath(ctx, azdClient) - if err != nil { - return "", err - } - agentCtx := loadLocalContext(configPath) - if agentCtx.Conversations == nil { - agentCtx.Conversations = make(map[string]string) - } + if !forceNew { - if convID, ok := agentCtx.Conversations[agentName]; ok { - return convID, nil + val, err := getContextValueWithFallback(ctx, azdClient, "conversations", agentKey, legacyKeys) + if err == nil && val != "" { + return val, nil } } @@ -391,10 +407,7 @@ func resolveConversationID( return "", fmt.Errorf("failed to create conversation: %w", err) } - agentCtx.Conversations[agentName] = newConvID - if err := saveLocalContext(agentCtx, configPath); err != nil { - return newConvID, fmt.Errorf("failed to save conversation ID: %w", err) - } + saveContextValue(ctx, azdClient, agentKey, newConvID, "conversations") return newConvID, nil } @@ -447,9 +460,10 @@ func fileExists(path string) bool { // AgentServiceInfo holds the resolved name and version for an agent service. type AgentServiceInfo struct { - ServiceName string // azure.yaml service key - AgentName string // deployed agent name from env - Version string // deployed agent version from env + ServiceName string // azure.yaml service key + AgentName string // deployed agent name from env + Version string // deployed agent version from env + AgentEndpoint string // full AGENT_{SVC}_ENDPOINT URL (includes name + version) } // promptForAgentService prompts the user to select one of multiple azure.ai.agent services. @@ -592,6 +606,14 @@ func resolveAgentServiceFromProject(ctx context.Context, azdClient *azdext.AzdCl info.Version = v.Value } + endpointKey := fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey) + if v, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResponse.Environment.Name, + Key: endpointKey, + }); err == nil && v.Value != "" { + info.AgentEndpoint = v.Value + } + return info, nil } 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 d1d120b7562..7599056a000 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "os" @@ -261,12 +262,18 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { // Resolve local session and conversation IDs (always generated locally). var sid, convID string if azdClient != nil { - sid, _ = resolveStoredID( + sid, err = resolveStoredID( ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", true, ) - convID, _ = resolveStoredID( + if err != nil { + log.Printf("invoke local: failed to resolve session ID: %v", err) + } + convID, err = resolveStoredID( ctx, azdClient, agentKey, a.flags.conversation, a.flags.newConversation, "conversations", true, ) + if err != nil { + log.Printf("invoke local: failed to resolve conversation ID: %v", err) + } } fmt.Printf("Target: localhost:%d (local)\n", port) @@ -340,12 +347,14 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { defer azdClient.Close() name := a.flags.name + var agentEndpoint string - // Auto-resolve agent name from azure.yaml + // Auto-resolve agent name and version from azure.yaml if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, rootFlags.NoPrompt); err == nil { if name == "" && info.AgentName != "" { name = info.AgentName } + agentEndpoint = info.AgentEndpoint } if name == "" { @@ -357,6 +366,15 @@ 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 agentEndpoint != "" { + agentKey = buildRemoteAgentKeyFromEndpoint(agentEndpoint) + } else { + log.Printf("warning: agent endpoint not available, session state will not be persisted") + } + body, bodyLabel, err := a.resolveBody() if err != nil { return err @@ -372,9 +390,17 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { // Session ID — routes to the same microVM container instance. // When empty, let the server assign one. - sid, err := resolveStoredID(ctx, azdClient, name, a.flags.session, a.flags.newSession, "sessions", false) - if err != nil { - return err + var sid string + if agentKey != "" { + sid, err = resolveStoredID( + ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", false, + legacyKeysForRemote(name)..., + ) + if err != nil { + return err + } + } else if a.flags.session != "" { + sid = a.flags.session } if sid != "" { reqBody["session_id"] = sid @@ -397,11 +423,13 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { convID, err := resolveConversationID( ctx, azdClient, - name, + agentKey, a.flags.conversation, a.flags.newConversation, projectEndpoint, token.Token, + name, + legacyKeysForRemote(name)..., ) if err != nil { return err @@ -442,7 +470,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { fmt.Printf("Trace ID: %s\n", requestID) } - captureResponseSession(ctx, azdClient, name, sid, resp, "Session: ") + captureResponseSession(ctx, azdClient, agentKey, sid, resp, "Session: ") if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) @@ -472,9 +500,12 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { // Resolve local session ID (generated locally, not server-assigned). var sid string if azdClient != nil { - sid, _ = resolveStoredID( + sid, err = resolveStoredID( ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", true, ) + if err != nil { + log.Printf("invoke local: failed to resolve session ID: %v", err) + } } fmt.Printf("Target: localhost:%d (local, invocations protocol)\n", port) @@ -510,9 +541,9 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { } defer resp.Body.Close() - // Persist the most recent invocation ID for this agent (best-effort). - if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" && azdClient != nil { - saveContextValue(ctx, azdClient, agentKey, invID, "invocations") + // 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) } return handleInvocationResponse(ctx, resp, "", "", agentKey, a.httpTimeout()) @@ -528,12 +559,14 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { 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, rootFlags.NoPrompt); err == nil { if name == "" && info.AgentName != "" { name = info.AgentName } + agentEndpoint = info.AgentEndpoint } if name == "" { @@ -547,15 +580,27 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { 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() if err != nil { return err } // Session ID — routes to the same container instance - sid, err := resolveStoredID(ctx, azdClient, name, a.flags.session, a.flags.newSession, "sessions", false) - if err != nil { - return err + var sid string + if agentKey != "" { + sid, err = resolveStoredID(ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", false) + if err != nil { + return err + } + } else if a.flags.session != "" { + sid = a.flags.session } // Acquire credential and token @@ -600,12 +645,12 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { } defer resp.Body.Close() - // Persist the most recent invocation ID for this agent. + // Print the invocation ID if the agent returned one. if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" { - saveContextValue(ctx, azdClient, name, invID, "invocations") + fmt.Printf("Invocation: %s\n", invID) } - captureResponseSession(ctx, azdClient, name, sid, resp, "Session: ") + captureResponseSession(ctx, azdClient, agentKey, sid, resp, "Session: ") return handleInvocationResponse(ctx, resp, endpoint, token.Token, name, a.httpTimeout()) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 5e7b6335985..b73b0e327c8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "log" "net/url" "os" "path/filepath" @@ -66,6 +67,9 @@ func newListenCommand() *cobra.Command { }). WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { return postdeployHandler(ctx, azdClient, args) + }). + WithProjectEventHandler("postdown", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return postdownHandler(ctx, azdClient, args) }) // Start listening for events @@ -259,6 +263,53 @@ func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *a return nil } +// postdownHandler cleans up config store entries (sessions, conversations) for agent services +// that were torn down. This is best-effort — failures are logged but do not block azd down. +func postdownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + log.Printf("postdown: failed to get current environment: %v", err) + return nil + } + + envName := envResp.Environment.Name + + for _, svc := range args.Project.Services { + if svc.Host != AiAgentHost { + continue + } + + serviceKey := toServiceKey(svc.Name) + + endpointResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), + }) + if err != nil || endpointResp.Value == "" { + continue + } + + agentKey := buildRemoteAgentKeyFromEndpoint(endpointResp.Value) + + // Remove stored sessions and conversations for this agent. + var failed bool + if err := deleteContextValue(ctx, azdClient, "sessions", agentKey); err != nil { + log.Printf("postdown: failed to clean sessions for %s: %v", agentKey, err) + failed = true + } + if err := deleteContextValue(ctx, azdClient, "conversations", agentKey); err != nil { + log.Printf("postdown: failed to clean conversations for %s: %v", agentKey, err) + failed = true + } + + if !failed { + fmt.Printf("Cleaned up saved session and conversation for agent %q\n", svc.Name) + } + } + + return nil +} + func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azdext.ProjectConfig, svc *azdext.ServiceConfig) error { var foundryAgentConfig *project.ServiceTargetAgentConfig diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go index 18c02a83f33..79f1177b0fa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go @@ -102,7 +102,14 @@ configuration and the current azd environment. Optionally specify the service na // Resolve session ID for session-based logstream. if flags.sessionID == "" { - sessionID := resolveMonitorSession(ctx, info.AgentName) + var sessionID string + if info.AgentEndpoint != "" { + sessionID = resolveMonitorSession( + ctx, + buildRemoteAgentKeyFromEndpoint(info.AgentEndpoint), + legacyKeysForRemote(info.AgentName)..., + ) + } if sessionID == "" { return exterrors.Validation( exterrors.CodeInvalidSessionId, @@ -186,26 +193,25 @@ func validateMonitorFlags(flags *monitorFlags) error { return nil } -// resolveMonitorSession resolves the session ID from the .foundry-agent.json file. +// resolveMonitorSession resolves the session ID from the config store. // Returns the session ID if available, or empty string if not found. -func resolveMonitorSession(ctx context.Context, agentName string) string { +func resolveMonitorSession(ctx context.Context, agentKey string, legacyKeys ...string) string { azdClient, err := azdext.NewAzdClient() if err != nil { return "" } defer azdClient.Close() - // Resolve session ID from .foundry-agent.json - configPath, err := resolveConfigPath(ctx, azdClient) - if err != nil { + // Try to trigger migration from legacy file if it exists. + migrateFromLegacyFile(ctx, azdClient) + + // Look up session using the agent key (with legacy fallback). + val, err := getContextValueWithFallback( + ctx, azdClient, "sessions", agentKey, legacyKeys, + ) + if err != nil || val == "" { return "" } - agentCtx := loadLocalContext(configPath) - if agentCtx.Sessions != nil { - if sid, ok := agentCtx.Sessions[agentName]; ok { - return sid - } - } - return "" + return val } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go index 5fe984ef6db..7a0e96a7303 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go @@ -252,200 +252,135 @@ func TestLoadLocalContext_EmptyFile(t *testing.T) { assert.Nil(t, loaded.Sessions) } -func TestSaveAndLoadLocalContext_RoundTrip(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - configPath := filepath.Join(dir, ConfigFile) - - original := &AgentLocalContext{ - AgentName: "echo-bot", - Sessions: map[string]string{ - "echo-bot": "sess-abc", - }, - Conversations: map[string]string{ - "echo-bot": "conv-xyz", - }, - } - - require.NoError(t, saveLocalContext(original, configPath)) - - loaded := loadLocalContext(configPath) - assert.Equal(t, original.AgentName, loaded.AgentName) - assert.Equal(t, original.Sessions, loaded.Sessions) - assert.Equal(t, original.Conversations, loaded.Conversations) -} - -func TestMonitorFlags_SessionBranchingDecision(t *testing.T) { +func TestBuildAgentKey(t *testing.T) { t.Parallel() tests := []struct { - name string - sessionID string - wantSessionLog bool + name string + endpoint string + agent string + version string + local bool + want string }{ { - name: "session ID set uses session logstream", - sessionID: "session-abc-123", - wantSessionLog: true, + name: "remote with version", + endpoint: "https://myaccount.services.ai.azure.com/api/projects/myproject", + agent: "my-agent", + version: "3", + local: false, + want: "myaccount.services.ai.azure.com/api/projects/myproject/agents/my-agent/versions/3/remote", + }, + { + name: "remote without version defaults to latest", + endpoint: "https://myaccount.services.ai.azure.com/api/projects/myproject", + agent: "my-agent", + version: "", + local: false, + want: "myaccount.services.ai.azure.com/api/projects/myproject/agents/my-agent/versions/latest/remote", }, { - name: "empty session ID uses container logstream", - sessionID: "", - wantSessionLog: false, + name: "local mode", + endpoint: "localhost:8088", + agent: "test-agent", + version: "latest", + local: true, + want: "localhost:8088/agents/test-agent/versions/latest/local", + }, + { + name: "trailing slash trimmed from endpoint", + endpoint: "https://example.com/", + agent: "agent", + version: "1", + local: false, + want: "example.com/agents/agent/versions/1/remote", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - flags := &monitorFlags{ - sessionID: tt.sessionID, - tail: 50, - logType: "console", - } - // The branching logic in MonitorAction.Run checks flags.sessionID != "" - useSessionLog := flags.sessionID != "" - assert.Equal(t, tt.wantSessionLog, useSessionLog) + got := buildAgentKey(tt.endpoint, tt.agent, tt.version, tt.local) + assert.Equal(t, tt.want, got) }) } } -func TestValidateMonitorFlags_AllCombinations(t *testing.T) { +func TestNormalizeEndpoint(t *testing.T) { t.Parallel() tests := []struct { - name string - flags monitorFlags - wantErr string + input string + want string }{ - // Both invalid: tail checked first - { - name: "tail and type both invalid", - flags: monitorFlags{tail: 0, logType: "invalid"}, - wantErr: "--tail must be between 1 and 300", - }, - // Valid tail, invalid type - { - name: "valid tail with empty type", - flags: monitorFlags{tail: 50, logType: ""}, - wantErr: "--type must be 'console' or 'system'", - }, - // Valid type, invalid tail - { - name: "system type with tail too high", - flags: monitorFlags{tail: 500, logType: "system"}, - wantErr: "--tail must be between 1 and 300", - }, + {"https://Example.COM/path/", "example.com/path"}, + {"https://example.com/path", "example.com/path"}, + {"HTTP://HOST.COM/", "host.com"}, + {"localhost:8088", "localhost:8088"}, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.input, func(t *testing.T) { t.Parallel() - err := validateMonitorFlags(&tt.flags) - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) + assert.Equal(t, tt.want, normalizeEndpoint(tt.input)) }) } } -func TestValidateMonitorFlags_ErrorMessages(t *testing.T) { +func TestProjectHash(t *testing.T) { t.Parallel() - // Verify that validation error messages include the actual bad value - flags := &monitorFlags{tail: 999, logType: "console"} - err := validateMonitorFlags(flags) - require.Error(t, err) - assert.Contains(t, err.Error(), "999") + // Same path produces same hash + h1 := projectHash("/some/path") + h2 := projectHash("/some/path") + assert.Equal(t, h1, h2) + + // Different paths produce different hashes + h3 := projectHash("/other/path") + assert.NotEqual(t, h1, h3) - flags = &monitorFlags{tail: 50, logType: "badtype"} - err = validateMonitorFlags(flags) - require.Error(t, err) - assert.Contains(t, err.Error(), "badtype") + // Hash is 16 hex chars (8 bytes) + assert.Len(t, h1, 16) } -func TestExplicitSessionID_PersistedAndReusable(t *testing.T) { +func TestLegacyKeysForRemote(t *testing.T) { t.Parallel() - dir := t.TempDir() - configPath := filepath.Join(dir, ConfigFile) - - agentName := "my-agent" - explicitSID := "user-provided-session-id" - - // Before: no sessions stored - agentCtx := loadLocalContext(configPath) - assert.Empty(t, agentCtx.Sessions) - - // Call resolveStoredIDFromPath with an explicit ID — this is the code path - // that resolveStoredID follows when the user passes --session-id. - got, err := resolveStoredIDFromPath(configPath, agentName, explicitSID, false, "sessions", false) - require.NoError(t, err) - assert.Equal(t, explicitSID, got) - - // Verify the ID was persisted (as monitor's resolveMonitorSession would load it) - loaded := loadLocalContext(configPath) - require.NotNil(t, loaded.Sessions) - assert.Equal(t, explicitSID, loaded.Sessions[agentName]) + keys := legacyKeysForRemote("my-agent") + assert.Contains(t, keys, "my-agent") } -func TestExplicitConversationID_PersistedAndReusable(t *testing.T) { +func TestLegacyKeysForLocal(t *testing.T) { t.Parallel() - dir := t.TempDir() - configPath := filepath.Join(dir, ConfigFile) + keys := legacyKeysForLocal("my-service", "my-agent") + assert.Contains(t, keys, "my-service-local") + assert.Contains(t, keys, "my-agent-local") + assert.Contains(t, keys, "local") +} - agentName := "my-agent" - explicitConvID := "user-provided-conv-id" +func TestResolveStoredIDFromPath_ExplicitID(t *testing.T) { + t.Parallel() - got, err := resolveStoredIDFromPath(configPath, agentName, explicitConvID, false, "conversations", false) + got, err := resolveStoredIDFromPath("", "agent-key", "explicit-id", false, "sessions", false) require.NoError(t, err) - assert.Equal(t, explicitConvID, got) - - loaded := loadLocalContext(configPath) - require.NotNil(t, loaded.Conversations) - assert.Equal(t, explicitConvID, loaded.Conversations[agentName]) + assert.Equal(t, "explicit-id", got) } -func TestExplicitID_OverwritesPreviousValue(t *testing.T) { +func TestResolveStoredIDFromPath_GenerateWhenMissing(t *testing.T) { t.Parallel() - dir := t.TempDir() - configPath := filepath.Join(dir, ConfigFile) - - agentName := "my-agent" - - // Persist an initial session ID via resolveStoredIDFromPath - got, err := resolveStoredIDFromPath(configPath, agentName, "old-session-id", false, "sessions", false) + got, err := resolveStoredIDFromPath("", "agent-key", "", false, "sessions", true) require.NoError(t, err) - assert.Equal(t, "old-session-id", got) - - // Persist a different explicit session ID — should overwrite the previous one - got, err = resolveStoredIDFromPath(configPath, agentName, "new-session-id", false, "sessions", false) - require.NoError(t, err) - assert.Equal(t, "new-session-id", got) - - loaded := loadLocalContext(configPath) - assert.Equal(t, "new-session-id", loaded.Sessions[agentName]) + assert.NotEmpty(t, got) + // Should be a UUID format + assert.Len(t, got, 36) } -func TestValidateMonitorFlags_SessionDoesNotAffectValidation(t *testing.T) { +func TestResolveStoredIDFromPath_EmptyWhenNotGenerating(t *testing.T) { t.Parallel() - // Session flag doesn't bypass tail/type validation - flags := &monitorFlags{ - sessionID: "some-session", - tail: 0, - logType: "console", - } - err := validateMonitorFlags(flags) - assert.Error(t, err, "session ID should not bypass tail validation") - - flags = &monitorFlags{ - sessionID: "some-session", - tail: 50, - logType: "invalid", - } - err = validateMonitorFlags(flags) - assert.Error(t, err, "session ID should not bypass type validation") + got, err := resolveStoredIDFromPath("", "agent-key", "", false, "sessions", false) + require.NoError(t, err) + assert.Empty(t, got) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index b48a4b16cc4..9648d59a314 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "log" "os" "os/exec" "os/signal" @@ -89,6 +90,14 @@ func runRun(ctx context.Context, flags *runFlags) error { } projectDir := runCtx.ProjectDir + // Clean up stored local session when the agent process exits. + localAgentKey := resolveLocalAgentKeyWithPort(ctx, azdClient, runCtx.ServiceName, rootFlags.NoPrompt, flags.port) + defer func() { + if err := deleteContextValue(ctx, azdClient, "sessions", localAgentKey); err != nil { + log.Printf("run: failed to clear stored local session: %v", err) + } + }() + // Detect project type early — used for both start-command resolution and // environment setup (e.g., setting ASPNETCORE_URLS for .NET). pt := detectProjectType(projectDir) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go index f699bb583a5..b3d35c8282e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "os" "text/tabwriter" @@ -82,9 +83,10 @@ func addSessionFlags(cmd *cobra.Command, flags *sessionFlags) { // sessionContext holds the resolved agent context for session operations. type sessionContext struct { - endpoint string - agentName string - version string // from AGENT_{SERVICE}_VERSION env var + endpoint string // project endpoint (for API calls) + agentName string + version string // from AGENT_{SERVICE}_VERSION env var + agentEndpoint string // full AGENT_{SVC}_ENDPOINT (for config key) } // resolveSessionContext resolves the agent name, version, and project endpoint. @@ -99,6 +101,7 @@ func resolveSessionContext( name := agentName var version string + var agentEndpoint string if info, err := resolveAgentServiceFromProject( ctx, azdClient, name, rootFlags.NoPrompt, @@ -109,6 +112,9 @@ func resolveSessionContext( if info.Version != "" { version = info.Version } + if info.AgentEndpoint != "" { + agentEndpoint = info.AgentEndpoint + } } if name == "" { @@ -126,9 +132,10 @@ func resolveSessionContext( } return &sessionContext{ - endpoint: endpoint, - agentName: name, - version: version, + endpoint: endpoint, + agentName: name, + version: version, + agentEndpoint: agentEndpoint, }, nil } @@ -280,7 +287,9 @@ func (a *SessionCreateAction) Run(ctx context.Context) error { ) } - persistSessionID(ctx, sc.agentName, session.AgentSessionID) + if sc.agentEndpoint != "" { + persistSessionID(ctx, buildRemoteAgentKeyFromEndpoint(sc.agentEndpoint), session.AgentSessionID) + } return printSession(session, a.flags.output) } @@ -471,6 +480,21 @@ func (a *SessionDeleteAction) Run(ctx context.Context) error { "Session %q deleted from agent %q.\n", a.sessionID, sc.agentName, ) + + // Best-effort: clear stored session if it matches the one we just deleted. + if sc.agentEndpoint != "" { + if azdClient, err := azdext.NewAzdClient(); err == nil { + defer azdClient.Close() + agentKey := buildRemoteAgentKeyFromEndpoint(sc.agentEndpoint) + if stored, err := getAgentSpecificContextValue(ctx, azdClient, "sessions", agentKey); err == nil && + stored == a.sessionID { + if err := deleteContextValue(ctx, azdClient, "sessions", agentKey); err != nil { + log.Printf("session delete: failed to clear stored session: %v", err) + } + } + } + } + return nil } @@ -688,8 +712,8 @@ func formatUnixTimestamp(epoch int64) string { return time.Unix(epoch, 0).UTC().Format(time.RFC3339) } -// persistSessionID saves the session ID to .foundry-agent.json for reuse. -func persistSessionID(ctx context.Context, agentName, sessionID string) { +// persistSessionID saves the session ID to the config store for reuse. +func persistSessionID(ctx context.Context, agentKey, sessionID string) { if sessionID == "" { return } @@ -700,5 +724,5 @@ func persistSessionID(ctx context.Context, agentName, sessionID string) { } defer azdClient.Close() - saveContextValue(ctx, azdClient, agentName, sessionID, "sessions") + saveContextValue(ctx, azdClient, agentKey, sessionID, "sessions") }