diff --git a/dotnet/AGENTS.md b/dotnet/AGENTS.md
index 03a015f2f7..4cb4b67e5f 100644
--- a/dotnet/AGENTS.md
+++ b/dotnet/AGENTS.md
@@ -29,6 +29,7 @@ using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, `AIFunct
## Key Conventions
+- **Encoding**: All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly.
- **Copyright header**: `// Copyright (c) Microsoft. All rights reserved.` at top of all `.cs` files
- **XML docs**: Required for all public methods and classes
- **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask`
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index b3a1fd81f3..f01cd409d6 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -138,6 +138,7 @@
+
@@ -424,6 +425,7 @@
+
@@ -445,6 +447,7 @@
+
@@ -467,6 +470,7 @@
+
diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj
new file mode 100644
index 0000000000..0b6c06a5a8
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs
new file mode 100644
index 0000000000..b3533e6d1d
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample shows how to use the FoundryMemoryProvider to persist and recall memories for an agent.
+// The sample stores conversation messages in an Azure AI Foundry memory store and retrieves relevant
+// memories for subsequent invocations, even across new sessions.
+//
+// Note: Memory extraction in Azure AI Foundry is asynchronous and takes time. This sample demonstrates
+// a simple polling approach to wait for memory updates to complete before querying.
+
+using System.Text.Json;
+using Azure.AI.Projects;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.FoundryMemory;
+
+string foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
+string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MEMORY_STORE_NAME") ?? "memory-store-sample";
+string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MODEL") ?? "gpt-4.1-mini";
+string embeddingModelName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_EMBEDDING_MODEL") ?? "text-embedding-ada-002";
+
+// Create an AIProjectClient for Foundry with Azure Identity authentication.
+DefaultAzureCredential credential = new();
+AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential);
+
+// Get the ChatClient from the AIProjectClient's OpenAI property using the deployment name.
+// The stateInitializer can be used to customize the Foundry Memory scope per session and it will be called each time a session
+// is encountered by the FoundryMemoryProvider that does not already have state stored on the session.
+// If each session should have its own scope, you can create a new id per session via the stateInitializer, e.g.:
+// new FoundryMemoryProvider(projectClient, memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope(Guid.NewGuid().ToString())), ...)
+// In our case we are storing memories scoped by user so that memories are retained across sessions.
+FoundryMemoryProvider memoryProvider = new(
+ projectClient,
+ memoryStoreName,
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123")));
+
+AIAgent agent = await projectClient.CreateAIAgentAsync(deploymentName,
+ options: new ChatClientAgentOptions()
+ {
+ Name = "TravelAssistantWithFoundryMemory",
+ ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." },
+ AIContextProviders = [memoryProvider]
+ });
+
+AgentSession session = await agent.CreateSessionAsync();
+
+Console.WriteLine("\n>> Setting up Foundry Memory Store\n");
+
+// Ensure the memory store exists (creates it with the specified models if needed).
+await memoryProvider.EnsureMemoryStoreCreatedAsync(deploymentName, embeddingModelName, "Sample memory store for travel assistant");
+
+// Clear any existing memories for this scope to demonstrate fresh behavior.
+await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
+
+Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session));
+Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session));
+
+// Memory extraction in Azure AI Foundry is asynchronous and takes time to process.
+// WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete.
+Console.WriteLine("\nWaiting for Foundry Memory to process updates...");
+await memoryProvider.WhenUpdatesCompletedAsync();
+
+Console.WriteLine("Updates completed.\n");
+
+Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session));
+
+Console.WriteLine("\n>> Serialize and deserialize the session to demonstrate persisted state\n");
+JsonElement serializedSession = await agent.SerializeSessionAsync(session);
+AgentSession restoredSession = await agent.DeserializeSessionAsync(serializedSession);
+Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredSession));
+
+Console.WriteLine("\n>> Start a new session that shares the same Foundry Memory scope\n");
+
+Console.WriteLine("\nWaiting for Foundry Memory to process updates...");
+await memoryProvider.WhenUpdatesCompletedAsync();
+
+AgentSession newSession = await agent.CreateSessionAsync();
+Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newSession));
diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md
new file mode 100644
index 0000000000..dfea386d82
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md
@@ -0,0 +1,57 @@
+# Agent with Memory Using Azure AI Foundry
+
+This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories across sessions.
+
+## Features Demonstrated
+
+- Creating a `FoundryMemoryProvider` with Azure Identity authentication
+- Automatic memory store creation if it doesn't exist
+- Multi-turn conversations with automatic memory extraction
+- Memory retrieval to inform agent responses
+- Session serialization and deserialization
+- Memory persistence across completely new sessions
+
+## Prerequisites
+
+1. Azure subscription with Azure AI Foundry project
+2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002)
+3. .NET 10.0 SDK
+4. Azure CLI logged in (`az login`)
+
+## Environment Variables
+
+```bash
+# Azure AI Foundry project endpoint and memory store name
+export FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project"
+export FOUNDRY_PROJECT_MEMORY_STORE_NAME="my_memory_store"
+
+# Model deployment names (models deployed in your Foundry project)
+export FOUNDRY_PROJECT_MODEL="gpt-4o-mini"
+export FOUNDRY_PROJECT_EMBEDDING_MODEL="text-embedding-ada-002"
+```
+
+## Run the Sample
+
+```bash
+dotnet run
+```
+
+## Expected Output
+
+The agent will:
+1. Create the memory store if it doesn't exist (using the specified chat and embedding models)
+2. Learn your name (Taylor), travel destination (Patagonia), timing (November), companions (sister), and interests (scenic viewpoints)
+3. Wait for Foundry Memory to index the memories
+4. Recall those details when asked about the trip
+5. Demonstrate memory persistence across session serialization/deserialization
+6. Show that a brand new session can still access the same memories
+
+## Key Differences from Mem0
+
+| Aspect | Mem0 | Azure AI Foundry Memory |
+|--------|------|------------------------|
+| Authentication | API Key | Azure Identity (DefaultAzureCredential) |
+| Scope | ApplicationId, UserId, AgentId, ThreadId | Single `Scope` string |
+| Memory Types | Single memory store | User Profile + Chat Summary |
+| Hosting | Mem0 cloud or self-hosted | Azure AI Foundry managed service |
+| Store Creation | N/A (automatic) | Explicit via `EnsureMemoryStoreCreatedAsync` |
diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/README.md
index 903fcf1b78..6e36ba0511 100644
--- a/dotnet/samples/GettingStarted/AgentWithMemory/README.md
+++ b/dotnet/samples/GettingStarted/AgentWithMemory/README.md
@@ -7,3 +7,4 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.|
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|[Custom Memory Implementation](./AgentWithMemory_Step03_CustomMemory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
+|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs
new file mode 100644
index 0000000000..9e24703d92
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ClientModel;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.Projects;
+
+namespace Microsoft.Agents.AI.FoundryMemory;
+
+///
+/// Internal extension methods for to provide MemoryStores helper operations.
+///
+internal static class AIProjectClientExtensions
+{
+ ///
+ /// Creates a memory store if it doesn't already exist.
+ ///
+ internal static async Task CreateMemoryStoreIfNotExistsAsync(
+ this AIProjectClient client,
+ string memoryStoreName,
+ string? description,
+ string chatModel,
+ string embeddingModel,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await client.MemoryStores.GetMemoryStoreAsync(memoryStoreName, cancellationToken).ConfigureAwait(false);
+ return false; // Store already exists
+ }
+ catch (ClientResultException ex) when (ex.Status == 404)
+ {
+ // Store doesn't exist, create it
+ }
+
+ MemoryStoreDefaultDefinition definition = new(chatModel, embeddingModel);
+ await client.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, description, cancellationToken: cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs
new file mode 100644
index 0000000000..1a0dd4f4e2
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.FoundryMemory;
+
+///
+/// Provides JSON serialization utilities for the Foundry Memory provider.
+///
+internal static class FoundryMemoryJsonUtilities
+{
+ ///
+ /// Gets the default JSON serializer options for Foundry Memory operations.
+ ///
+ public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ TypeInfoResolver = FoundryMemoryJsonContext.Default
+ };
+}
+
+///
+/// Source-generated JSON serialization context for Foundry Memory types.
+///
+[JsonSourceGenerationOptions(
+ JsonSerializerDefaults.General,
+ UseStringEnumConverter = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ WriteIndented = false)]
+[JsonSerializable(typeof(FoundryMemoryProviderScope))]
+[JsonSerializable(typeof(FoundryMemoryProvider.State))]
+internal partial class FoundryMemoryJsonContext : JsonSerializerContext;
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs
new file mode 100644
index 0000000000..9ffeda3fb5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs
@@ -0,0 +1,440 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.ClientModel;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.Projects;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+using OpenAI.Responses;
+
+namespace Microsoft.Agents.AI.FoundryMemory;
+
+///
+/// Provides an Azure AI Foundry Memory backed that persists conversation messages as memories
+/// and retrieves related memories to augment the agent invocation context.
+///
+///
+/// The provider stores user, assistant and system messages as Foundry memories and retrieves relevant memories
+/// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages
+/// to the model, prefixed by a configurable context prompt.
+///
+[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
+public sealed class FoundryMemoryProvider : AIContextProvider
+{
+ private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
+
+ private readonly ProviderSessionState _sessionState;
+ private readonly string _contextPrompt;
+ private readonly string _memoryStoreName;
+ private readonly int _maxMemories;
+ private readonly int _updateDelay;
+ private readonly bool _enableSensitiveTelemetryData;
+
+ private readonly AIProjectClient _client;
+ private readonly ILogger? _logger;
+
+ private string? _lastPendingUpdateId;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Azure AI Project client configured for your Foundry project.
+ /// The name of the memory store in Azure AI Foundry.
+ /// A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval.
+ /// Provider options.
+ /// Optional logger factory.
+ /// Thrown when or is .
+ /// Thrown when is null or whitespace.
+ public FoundryMemoryProvider(
+ AIProjectClient client,
+ string memoryStoreName,
+ Func stateInitializer,
+ FoundryMemoryProviderOptions? options = null,
+ ILoggerFactory? loggerFactory = null)
+ : base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
+ {
+ Throw.IfNull(client);
+ Throw.IfNullOrWhitespace(memoryStoreName);
+
+ this._sessionState = new ProviderSessionState(
+ ValidateStateInitializer(Throw.IfNull(stateInitializer)),
+ options?.StateKey ?? this.GetType().Name,
+ FoundryMemoryJsonUtilities.DefaultOptions);
+
+ FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions();
+
+ this._logger = loggerFactory?.CreateLogger();
+ this._client = client;
+
+ this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt;
+ this._memoryStoreName = memoryStoreName;
+ this._maxMemories = effectiveOptions.MaxMemories;
+ this._updateDelay = effectiveOptions.UpdateDelay;
+ this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData;
+ }
+
+ ///
+ public override string StateKey => this._sessionState.StateKey;
+
+ private static Func ValidateStateInitializer(Func stateInitializer) =>
+ session =>
+ {
+ State state = stateInitializer(session);
+
+ if (state is null)
+ {
+ throw new InvalidOperationException("State initializer must return a non-null state.");
+ }
+
+ return state;
+ };
+
+ ///
+ protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(context);
+
+ State state = this._sessionState.GetOrInitializeState(context.Session);
+ FoundryMemoryProviderScope scope = state.Scope;
+
+ List messageItems = (context.AIContext.Messages ?? [])
+ .Where(m => !string.IsNullOrWhiteSpace(m.Text))
+ .Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
+ .ToList();
+
+ if (messageItems.Count == 0)
+ {
+ return new AIContext();
+ }
+
+ try
+ {
+ MemorySearchOptions searchOptions = new(scope.Scope)
+ {
+ ResultOptions = new MemorySearchResultOptions { MaxMemories = this._maxMemories }
+ };
+
+ foreach (ResponseItem item in messageItems)
+ {
+ searchOptions.Items.Add(item);
+ }
+
+ ClientResult result = await this._client.MemoryStores.SearchMemoriesAsync(
+ this._memoryStoreName,
+ searchOptions,
+ cancellationToken).ConfigureAwait(false);
+
+ MemoryStoreSearchResponse response = result.Value;
+
+ List memories = response.Memories
+ .Select(m => m.MemoryItem?.Content ?? string.Empty)
+ .Where(c => !string.IsNullOrWhiteSpace(c))
+ .ToList();
+
+ string? outputMessageText = memories.Count == 0
+ ? null
+ : $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
+
+ if (this._logger?.IsEnabled(LogLevel.Information) is true)
+ {
+ this._logger.LogInformation(
+ "FoundryMemoryProvider: Retrieved {Count} memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
+ memories.Count,
+ this._memoryStoreName,
+ this.SanitizeLogData(scope.Scope));
+
+ if (outputMessageText is not null && this._logger.IsEnabled(LogLevel.Trace))
+ {
+ this._logger.LogTrace(
+ "FoundryMemoryProvider: Search Results\nOutput:{MessageText}\nMemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
+ this.SanitizeLogData(outputMessageText),
+ this._memoryStoreName,
+ this.SanitizeLogData(scope.Scope));
+ }
+ }
+
+ return new AIContext
+ {
+ Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
+ };
+ }
+ catch (ArgumentException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ if (this._logger?.IsEnabled(LogLevel.Error) is true)
+ {
+ this._logger.LogError(
+ ex,
+ "FoundryMemoryProvider: Failed to search for memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
+ this._memoryStoreName,
+ this.SanitizeLogData(scope.Scope));
+ }
+
+ return new AIContext();
+ }
+ }
+
+ ///
+ protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
+ {
+ State state = this._sessionState.GetOrInitializeState(context.Session);
+ FoundryMemoryProviderScope scope = state.Scope;
+
+ try
+ {
+ List messageItems = context.RequestMessages
+ .Concat(context.ResponseMessages ?? [])
+ .Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text))
+ .Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
+ .ToList();
+
+ if (messageItems.Count == 0)
+ {
+ return;
+ }
+
+ MemoryUpdateOptions updateOptions = new(scope.Scope)
+ {
+ UpdateDelay = this._updateDelay
+ };
+
+ foreach (ResponseItem item in messageItems)
+ {
+ updateOptions.Items.Add(item);
+ }
+
+ ClientResult result = await this._client.MemoryStores.UpdateMemoriesAsync(
+ this._memoryStoreName,
+ updateOptions,
+ cancellationToken).ConfigureAwait(false);
+
+ MemoryUpdateResult response = result.Value;
+
+ if (response.UpdateId is not null)
+ {
+ Interlocked.Exchange(ref this._lastPendingUpdateId, response.UpdateId);
+ }
+
+ if (this._logger?.IsEnabled(LogLevel.Information) is true)
+ {
+ this._logger.LogInformation(
+ "FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}', UpdateId: '{UpdateId}'.",
+ messageItems.Count,
+ this._memoryStoreName,
+ this.SanitizeLogData(scope.Scope),
+ response.UpdateId);
+ }
+ }
+ catch (Exception ex)
+ {
+ if (this._logger?.IsEnabled(LogLevel.Error) is true)
+ {
+ this._logger.LogError(
+ ex,
+ "FoundryMemoryProvider: Failed to send messages to update memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
+ this._memoryStoreName,
+ this.SanitizeLogData(scope.Scope));
+ }
+ }
+ }
+
+ ///
+ /// Ensures all stored memories for the configured scope are deleted.
+ /// This method handles cases where the scope doesn't exist (no memories stored yet).
+ ///
+ /// The session containing the scope state to clear memories for.
+ /// Cancellation token.
+ public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(session);
+ State state = this._sessionState.GetOrInitializeState(session);
+ FoundryMemoryProviderScope scope = state.Scope;
+
+ try
+ {
+ await this._client.MemoryStores.DeleteScopeAsync(this._memoryStoreName, scope.Scope, cancellationToken).ConfigureAwait(false);
+
+ if (this._logger?.IsEnabled(LogLevel.Information) is true)
+ {
+ this._logger.LogInformation(
+ "FoundryMemoryProvider: Deleted stored memories for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
+ this._memoryStoreName,
+ this.SanitizeLogData(scope.Scope));
+ }
+ }
+ catch (ClientResultException ex) when (ex.Status == 404)
+ {
+ // Scope doesn't exist (no memories stored yet), nothing to delete
+ if (this._logger?.IsEnabled(LogLevel.Debug) is true)
+ {
+ this._logger.LogDebug(
+ "FoundryMemoryProvider: No memories to delete for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
+ this._memoryStoreName,
+ this.SanitizeLogData(scope.Scope));
+ }
+ }
+ }
+
+ ///
+ /// Ensures the memory store exists, creating it if necessary.
+ ///
+ /// The deployment name of the chat model for memory processing.
+ /// The deployment name of the embedding model for memory search.
+ /// Optional description for the memory store.
+ /// Cancellation token.
+ public async Task EnsureMemoryStoreCreatedAsync(
+ string chatModel,
+ string embeddingModel,
+ string? description = null,
+ CancellationToken cancellationToken = default)
+ {
+ bool created = await this._client.CreateMemoryStoreIfNotExistsAsync(
+ this._memoryStoreName,
+ description,
+ chatModel,
+ embeddingModel,
+ cancellationToken).ConfigureAwait(false);
+
+ if (created)
+ {
+ if (this._logger?.IsEnabled(LogLevel.Information) is true)
+ {
+ this._logger.LogInformation(
+ "FoundryMemoryProvider: Created memory store '{MemoryStoreName}'.",
+ this._memoryStoreName);
+ }
+ }
+ else
+ {
+ if (this._logger?.IsEnabled(LogLevel.Debug) is true)
+ {
+ this._logger.LogDebug(
+ "FoundryMemoryProvider: Memory store '{MemoryStoreName}' already exists.",
+ this._memoryStoreName);
+ }
+ }
+ }
+
+ ///
+ /// Waits for all pending memory update operations to complete.
+ ///
+ ///
+ /// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update
+ /// and returns when it has completed, failed, or been superseded. Since updates are processed in order,
+ /// completion of the latest update implies all prior updates have also been processed.
+ ///
+ /// The interval between status checks. Defaults to 5 seconds.
+ /// Cancellation token.
+ /// Thrown if the update operation failed.
+ public async Task WhenUpdatesCompletedAsync(
+ TimeSpan? pollingInterval = null,
+ CancellationToken cancellationToken = default)
+ {
+ string? updateId = Volatile.Read(ref this._lastPendingUpdateId);
+ if (updateId is null)
+ {
+ return;
+ }
+
+ TimeSpan interval = pollingInterval ?? TimeSpan.FromSeconds(5);
+ await this.WaitForUpdateAsync(updateId, interval, cancellationToken).ConfigureAwait(false);
+
+ // Only clear the pending update ID after successful completion
+ Interlocked.CompareExchange(ref this._lastPendingUpdateId, null, updateId);
+ }
+
+ private async Task WaitForUpdateAsync(string updateId, TimeSpan interval, CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ClientResult result = await this._client.MemoryStores.GetUpdateResultAsync(
+ this._memoryStoreName,
+ updateId,
+ cancellationToken).ConfigureAwait(false);
+
+ MemoryUpdateResult response = result.Value;
+ MemoryStoreUpdateStatus status = response.Status;
+
+ if (this._logger?.IsEnabled(LogLevel.Debug) is true)
+ {
+ this._logger.LogDebug(
+ "FoundryMemoryProvider: Update status for '{UpdateId}': {Status}",
+ updateId,
+ status);
+ }
+
+ if (status == MemoryStoreUpdateStatus.Completed || status == MemoryStoreUpdateStatus.Superseded)
+ {
+ return;
+ }
+
+ if (status == MemoryStoreUpdateStatus.Failed)
+ {
+ throw new InvalidOperationException($"Memory update operation '{updateId}' failed: {response.ErrorDetails}");
+ }
+
+ if (status == MemoryStoreUpdateStatus.Queued || status == MemoryStoreUpdateStatus.InProgress)
+ {
+ await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unknown update status '{status}' for update '{updateId}'.");
+ }
+ }
+ }
+
+ private static MessageResponseItem ToResponseItem(ChatRole role, string text)
+ {
+ if (role == ChatRole.Assistant)
+ {
+ return ResponseItem.CreateAssistantMessageItem(text);
+ }
+
+ if (role == ChatRole.System)
+ {
+ return ResponseItem.CreateSystemMessageItem(text);
+ }
+
+ return ResponseItem.CreateUserMessageItem(text);
+ }
+
+ private static bool IsAllowedRole(ChatRole role) =>
+ role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System;
+
+ private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "";
+
+ ///
+ /// Represents the state of a stored in the .
+ ///
+ public sealed class State
+ {
+ ///
+ /// Initializes a new instance of the class with the specified scope.
+ ///
+ /// The scope to use for memory storage and retrieval.
+ [JsonConstructor]
+ public State(FoundryMemoryProviderScope scope)
+ {
+ this.Scope = Throw.IfNull(scope);
+ }
+
+ ///
+ /// Gets the scope used for memory storage and retrieval.
+ ///
+ public FoundryMemoryProviderScope Scope { get; }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs
new file mode 100644
index 0000000000..482e14db82
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs
@@ -0,0 +1,67 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.FoundryMemory;
+
+///
+/// Options for configuring the .
+///
+public sealed class FoundryMemoryProviderOptions
+{
+ ///
+ /// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
+ ///
+ /// Defaults to "## Memories\nConsider the following memories when answering user questions:".
+ public string? ContextPrompt { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of memories to retrieve during search.
+ ///
+ /// Defaults to 5.
+ public int MaxMemories { get; set; } = 5;
+
+ ///
+ /// Gets or sets the delay in seconds before memory updates are processed.
+ ///
+ ///
+ /// Setting to 0 triggers updates immediately without waiting for inactivity.
+ /// Higher values allow the service to batch multiple updates together.
+ ///
+ /// Defaults to 0 (immediate).
+ public int UpdateDelay { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
+ ///
+ /// Defaults to .
+ public bool EnableSensitiveTelemetryData { get; set; }
+
+ ///
+ /// Gets or sets the key used to store the provider state in the session's .
+ ///
+ /// Defaults to the provider's type name.
+ public string? StateKey { get; set; }
+
+ ///
+ /// Gets or sets an optional filter function applied to request messages when building the search text to use when
+ /// searching for relevant memories during .
+ ///
+ ///
+ /// When , the provider defaults to including only
+ /// messages.
+ ///
+ public Func, IEnumerable>? SearchInputMessageFilter { get; set; }
+
+ ///
+ /// Gets or sets an optional filter function applied to request messages when determining which messages to
+ /// extract memories from during .
+ ///
+ ///
+ /// When , the provider defaults to including only
+ /// messages.
+ ///
+ public Func, IEnumerable>? StorageInputMessageFilter { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs
new file mode 100644
index 0000000000..717df1d12b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.FoundryMemory;
+
+///
+/// Allows scoping of memories for the .
+///
+///
+/// Azure AI Foundry memories are scoped by a single string identifier that you control.
+/// Common patterns include using a user ID, team ID, or other unique identifier
+/// to partition memories across different contexts.
+///
+public sealed class FoundryMemoryProviderScope
+{
+ ///
+ /// Initializes a new instance of the class with the specified scope identifier.
+ ///
+ /// The scope identifier used to partition memories. Must not be null or whitespace.
+ /// Thrown when is null or whitespace.
+ public FoundryMemoryProviderScope(string scope)
+ {
+ Throw.IfNullOrWhitespace(scope);
+ this.Scope = scope;
+ }
+
+ ///
+ /// Gets the scope identifier used to partition memories.
+ ///
+ ///
+ /// This value controls how memory is partitioned in the memory store.
+ /// Each unique scope maintains its own isolated collection of memory items.
+ /// For example, use a user ID to ensure each user has their own individual memory.
+ ///
+ public string Scope { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj
new file mode 100644
index 0000000000..75da2bccc5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj
@@ -0,0 +1,41 @@
+
+
+
+ preview
+ $(NoWarn);OPENAI001
+
+
+
+ true
+ true
+ true
+ true
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Microsoft Agent Framework - Azure AI Foundry Memory integration
+ Provides Azure AI Foundry Memory integration for Microsoft Agent Framework.
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs b/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs
new file mode 100644
index 0000000000..957f1bfa4c
--- /dev/null
+++ b/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Shared.IntegrationTests;
+
+#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
+#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
+
+internal sealed class FoundryMemoryConfiguration
+{
+ public string Endpoint { get; set; }
+ public string MemoryStoreName { get; set; }
+ public string? DeploymentName { get; set; }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs
new file mode 100644
index 0000000000..d89001d3b9
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs
@@ -0,0 +1,132 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading.Tasks;
+using Azure.AI.Projects;
+using Azure.Identity;
+using Microsoft.Extensions.Configuration;
+using Shared.IntegrationTests;
+
+namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests;
+
+///
+/// Integration tests for against a configured Azure AI Foundry Memory service.
+///
+///
+/// These integration tests are skipped by default and require a live Azure AI Foundry Memory service.
+/// The tests need to be updated to use the new AIAgent-based API pattern.
+/// Set to null to enable them after configuring the service.
+///
+public sealed class FoundryMemoryProviderTests : IDisposable
+{
+ private const string SkipReason = "Requires an Azure AI Foundry Memory service configured"; // Set to null to enable.
+
+ private readonly AIProjectClient? _client;
+ private readonly string? _memoryStoreName;
+ private readonly string? _deploymentName;
+ private bool _disposed;
+
+ public FoundryMemoryProviderTests()
+ {
+ IConfigurationRoot configuration = new ConfigurationBuilder()
+ .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true)
+ .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true)
+ .AddEnvironmentVariables()
+ .AddUserSecrets(optional: true)
+ .Build();
+
+ var foundrySettings = configuration.GetSection("FoundryMemory").Get();
+
+ if (foundrySettings is not null &&
+ !string.IsNullOrWhiteSpace(foundrySettings.Endpoint) &&
+ !string.IsNullOrWhiteSpace(foundrySettings.MemoryStoreName))
+ {
+ this._client = new AIProjectClient(new Uri(foundrySettings.Endpoint), new AzureCliCredential());
+ this._memoryStoreName = foundrySettings.MemoryStoreName;
+ this._deploymentName = foundrySettings.DeploymentName ?? "gpt-4.1-mini";
+ }
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task CanAddAndRetrieveUserMemoriesAsync()
+ {
+ // Arrange
+ FoundryMemoryProvider memoryProvider = new(
+ this._client!,
+ this._memoryStoreName!,
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1")));
+
+ AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!,
+ options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] });
+
+ AgentSession session = await agent.CreateSessionAsync();
+
+ await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
+
+ // Act
+ AgentResponse resultBefore = await agent.RunAsync("What is my name?", session);
+ Assert.DoesNotContain("Caoimhe", resultBefore.Text);
+
+ await agent.RunAsync("Hello, my name is Caoimhe.", session);
+ await memoryProvider.WhenUpdatesCompletedAsync();
+ await Task.Delay(2000);
+
+ AgentResponse resultAfter = await agent.RunAsync("What is my name?", session);
+
+ // Cleanup
+ await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
+
+ // Assert
+ Assert.Contains("Caoimhe", resultAfter.Text);
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task DoesNotLeakMemoriesAcrossScopesAsync()
+ {
+ // Arrange
+ FoundryMemoryProvider memoryProvider1 = new(
+ this._client!,
+ this._memoryStoreName!,
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-a")));
+
+ FoundryMemoryProvider memoryProvider2 = new(
+ this._client!,
+ this._memoryStoreName!,
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b")));
+
+ AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
+ options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] });
+ AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
+ options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] });
+
+ AgentSession session1 = await agent1.CreateSessionAsync();
+ AgentSession session2 = await agent2.CreateSessionAsync();
+
+ await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
+ await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
+
+ // Act - add memory only to scope A
+ await agent1.RunAsync("Hello, I'm an AI tutor and my name is Caoimhe.", session1);
+ await memoryProvider1.WhenUpdatesCompletedAsync();
+ await Task.Delay(2000);
+
+ AgentResponse result1 = await agent1.RunAsync("What is your name?", session1);
+ AgentResponse result2 = await agent2.RunAsync("What is your name?", session2);
+
+ // Assert
+ Assert.Contains("Caoimhe", result1.Text);
+ Assert.DoesNotContain("Caoimhe", result2.Text);
+
+ // Cleanup
+ await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
+ await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
+ }
+
+ public void Dispose()
+ {
+ if (!this._disposed)
+ {
+ this._disposed = true;
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj
new file mode 100644
index 0000000000..a28fea3490
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs
new file mode 100644
index 0000000000..226596a374
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.FoundryMemory.UnitTests;
+
+///
+/// Tests for constructor validation.
+///
+///
+/// Since directly uses ,
+/// integration tests are used to verify the memory operations. These unit tests focus on:
+/// - Constructor parameter validation
+/// - State initializer validation
+///
+public sealed class FoundryMemoryProviderTests
+{
+ [Fact]
+ public void Constructor_Throws_WhenClientIsNull()
+ {
+ // Act & Assert
+ ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider(
+ null!,
+ "store",
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("test"))));
+ Assert.Equal("client", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_Throws_WhenStateInitializerIsNull()
+ {
+ // Arrange
+ using TestableAIProjectClient testClient = new();
+
+ // Act & Assert
+ ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider(
+ testClient.Client,
+ "store",
+ stateInitializer: null!));
+ Assert.Equal("stateInitializer", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_Throws_WhenMemoryStoreNameIsEmpty()
+ {
+ // Arrange
+ using TestableAIProjectClient testClient = new();
+
+ // Act & Assert
+ ArgumentException ex = Assert.Throws(() => new FoundryMemoryProvider(
+ testClient.Client,
+ "",
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("test"))));
+ Assert.Equal("memoryStoreName", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_Throws_WhenMemoryStoreNameIsNull()
+ {
+ // Arrange
+ using TestableAIProjectClient testClient = new();
+
+ // Act & Assert
+ ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider(
+ testClient.Client,
+ null!,
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("test"))));
+ Assert.Equal("memoryStoreName", ex.ParamName);
+ }
+
+ [Fact]
+ public void Scope_Throws_WhenScopeIsNull()
+ {
+ // Act & Assert
+ Assert.Throws(() => new FoundryMemoryProviderScope(null!));
+ }
+
+ [Fact]
+ public void Scope_Throws_WhenScopeIsEmpty()
+ {
+ // Act & Assert
+ Assert.Throws(() => new FoundryMemoryProviderScope(""));
+ }
+
+ [Fact]
+ public void StateInitializer_Throws_WhenScopeIsNull()
+ {
+ // Arrange
+ using TestableAIProjectClient testClient = new();
+ FoundryMemoryProvider sut = new(
+ testClient.Client,
+ "store",
+ stateInitializer: _ => new(null!));
+
+ // Act & Assert - state initializer validation is deferred to first use
+ Assert.Throws(() =>
+ {
+ // Force state initialization by creating a session-like scenario
+ // The validation happens inside the ValidateStateInitializer wrapper
+ try
+ {
+ // The stateInitializer wraps with validation, so calling it will throw
+ var field = typeof(FoundryMemoryProvider).GetField("_sessionState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var sessionState = field!.GetValue(sut);
+ var method = sessionState!.GetType().GetMethod("GetOrInitializeState");
+ method!.Invoke(sessionState, [null]);
+ }
+ catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null)
+ {
+ throw tie.InnerException;
+ }
+ });
+ }
+
+ [Fact]
+ public void Constructor_Succeeds_WithValidParameters()
+ {
+ // Arrange
+ using TestableAIProjectClient testClient = new();
+
+ // Act
+ FoundryMemoryProvider sut = new(
+ testClient.Client,
+ "my-store",
+ stateInitializer: _ => new(new FoundryMemoryProviderScope("user-456")));
+
+ // Assert
+ Assert.NotNull(sut);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj
new file mode 100644
index 0000000000..1fe8dc57bd
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj
@@ -0,0 +1,16 @@
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs
new file mode 100644
index 0000000000..25c041f754
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs
@@ -0,0 +1,196 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.ClientModel.Primitives;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.Projects;
+using Azure.Core;
+
+namespace Microsoft.Agents.AI.FoundryMemory.UnitTests;
+
+///
+/// Creates a testable AIProjectClient with a mock HTTP handler.
+///
+internal sealed class TestableAIProjectClient : IDisposable
+{
+ private readonly HttpClient _httpClient;
+
+ public TestableAIProjectClient(
+ string? searchMemoriesResponse = null,
+ string? updateMemoriesResponse = null,
+ HttpStatusCode? searchStatusCode = null,
+ HttpStatusCode? updateStatusCode = null,
+ HttpStatusCode? deleteStatusCode = null,
+ HttpStatusCode? createStoreStatusCode = null,
+ HttpStatusCode? getStoreStatusCode = null)
+ {
+ this.Handler = new MockHttpMessageHandler(
+ searchMemoriesResponse,
+ updateMemoriesResponse,
+ searchStatusCode,
+ updateStatusCode,
+ deleteStatusCode,
+ createStoreStatusCode,
+ getStoreStatusCode);
+
+ this._httpClient = new HttpClient(this.Handler);
+
+ AIProjectClientOptions options = new()
+ {
+ Transport = new HttpClientPipelineTransport(this._httpClient)
+ };
+
+ // Using a valid format endpoint
+ this.Client = new AIProjectClient(
+ new Uri("https://test.services.ai.azure.com/api/projects/test-project"),
+ new MockTokenCredential(),
+ options);
+ }
+
+ public AIProjectClient Client { get; }
+
+ public MockHttpMessageHandler Handler { get; }
+
+ public void Dispose()
+ {
+ this._httpClient.Dispose();
+ this.Handler.Dispose();
+ }
+}
+
+///
+/// Mock HTTP message handler for testing.
+///
+internal sealed class MockHttpMessageHandler : HttpMessageHandler
+{
+ private readonly string? _searchMemoriesResponse;
+ private readonly string? _updateMemoriesResponse;
+ private readonly HttpStatusCode _searchStatusCode;
+ private readonly HttpStatusCode _updateStatusCode;
+ private readonly HttpStatusCode _deleteStatusCode;
+ private readonly HttpStatusCode _createStoreStatusCode;
+ private readonly HttpStatusCode _getStoreStatusCode;
+
+ public MockHttpMessageHandler(
+ string? searchMemoriesResponse = null,
+ string? updateMemoriesResponse = null,
+ HttpStatusCode? searchStatusCode = null,
+ HttpStatusCode? updateStatusCode = null,
+ HttpStatusCode? deleteStatusCode = null,
+ HttpStatusCode? createStoreStatusCode = null,
+ HttpStatusCode? getStoreStatusCode = null)
+ {
+ this._searchMemoriesResponse = searchMemoriesResponse ?? """{"memories":[]}""";
+ this._updateMemoriesResponse = updateMemoriesResponse ?? """{"update_id":"test-update-id","status":"queued"}""";
+ this._searchStatusCode = searchStatusCode ?? HttpStatusCode.OK;
+ this._updateStatusCode = updateStatusCode ?? HttpStatusCode.OK;
+ this._deleteStatusCode = deleteStatusCode ?? HttpStatusCode.NoContent;
+ this._createStoreStatusCode = createStoreStatusCode ?? HttpStatusCode.Created;
+ this._getStoreStatusCode = getStoreStatusCode ?? HttpStatusCode.NotFound;
+ }
+
+ public string? LastRequestUri { get; private set; }
+ public string? LastRequestBody { get; private set; }
+ public HttpMethod? LastRequestMethod { get; private set; }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ this.LastRequestUri = request.RequestUri?.ToString();
+ this.LastRequestMethod = request.Method;
+
+ if (request.Content != null)
+ {
+#if NET472
+ this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
+#else
+ this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+#endif
+ }
+
+ string path = request.RequestUri?.AbsolutePath ?? "";
+
+ // Route based on path and method
+ if (path.Contains("/memory-stores/") && path.Contains("/search") && request.Method == HttpMethod.Post)
+ {
+ return CreateResponse(this._searchStatusCode, this._searchMemoriesResponse);
+ }
+
+ if (path.Contains("/memory-stores/") && path.Contains("/memories") && request.Method == HttpMethod.Post)
+ {
+ return CreateResponse(this._updateStatusCode, this._updateMemoriesResponse);
+ }
+
+ if (path.Contains("/memory-stores/") && path.Contains("/scopes") && request.Method == HttpMethod.Delete)
+ {
+ return CreateResponse(this._deleteStatusCode, "");
+ }
+
+ if (path.Contains("/memory-stores") && request.Method == HttpMethod.Post)
+ {
+ return CreateResponse(this._createStoreStatusCode, """{"name":"test-store","status":"active"}""");
+ }
+
+ if (path.Contains("/memory-stores/") && request.Method == HttpMethod.Get)
+ {
+ return CreateResponse(this._getStoreStatusCode, """{"name":"test-store","status":"active"}""");
+ }
+
+ // Default response
+ return CreateResponse(HttpStatusCode.NotFound, "{}");
+ }
+
+ private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string? content)
+ {
+ return new HttpResponseMessage(statusCode)
+ {
+ Content = new StringContent(content ?? "{}", Encoding.UTF8, "application/json")
+ };
+ }
+}
+
+///
+/// Mock token credential for testing.
+///
+internal sealed class MockTokenCredential : TokenCredential
+{
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1));
+ }
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
+ }
+}
+
+///
+/// Source-generated JSON serializer context for unit test types.
+///
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+[JsonSerializable(typeof(TestState))]
+[JsonSerializable(typeof(TestScope))]
+internal sealed partial class TestJsonContext : JsonSerializerContext
+{
+}
+
+///
+/// Test state class for deserialization tests.
+///
+internal sealed class TestState
+{
+ public TestScope? Scope { get; set; }
+}
+
+///
+/// Test scope class for deserialization tests.
+///
+internal sealed class TestScope
+{
+ public string? Scope { get; set; }
+}