diff --git a/dotnet/agent-framework-durable-extension.slnx b/dotnet/agent-framework-durable-extension.slnx
index a88477f..ac242fd 100644
--- a/dotnet/agent-framework-durable-extension.slnx
+++ b/dotnet/agent-framework-durable-extension.slnx
@@ -22,6 +22,9 @@
+
+
+
diff --git a/dotnet/eng/verify-samples/DurableAgentSamples.cs b/dotnet/eng/verify-samples/DurableAgentSamples.cs
index 1ce5f50..5435b6c 100644
--- a/dotnet/eng/verify-samples/DurableAgentSamples.cs
+++ b/dotnet/eng/verify-samples/DurableAgentSamples.cs
@@ -9,6 +9,8 @@ internal static class DurableAgentSamples
{
private const string AzureFunctionsSkipReason =
"Requires Azure Functions Core Tools runtime and starts a web host.";
+ private const string ExperimentalMailboxSkipReason =
+ "Draft only: schema 2 mailbox writes remain behind an internal, default-disabled rollout gate.";
public static IReadOnlyList ConsoleApps { get; } =
[
@@ -91,6 +93,46 @@ internal static class DurableAgentSamples
"The output should not contain error messages or stack traces.",
],
},
+ new SampleDefinition
+ {
+ Name = "DurableAgents_Console_08_FoundryManagedAgent",
+ ProjectPath = "samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent",
+ SkipReason = ExperimentalMailboxSkipReason,
+ RequiredEnvironmentVariables =
+ [
+ "FOUNDRY_PROJECT_ENDPOINT",
+ "FOUNDRY_MODEL",
+ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
+ ],
+ MustContain =
+ [
+ "Host stopped. Starting a new host to force durable state restoration.",
+ "Marker recall after durable restart: PASS",
+ "The fixed service-owner binding and restored service conversation identity are verified by deterministic tests.",
+ "Deleted Foundry agent version:",
+ ],
+ IsDeterministic = true,
+ },
+ new SampleDefinition
+ {
+ Name = "DurableAgents_Console_09_CustomHistoryProvider",
+ ProjectPath = "samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider",
+ SkipReason = ExperimentalMailboxSkipReason,
+ RequiredEnvironmentVariables =
+ [
+ "FOUNDRY_PROJECT_ENDPOINT",
+ "FOUNDRY_MODEL",
+ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
+ ],
+ Inputs = ["SAMPLE-MARKER-09"],
+ ExpectedOutputDescription =
+ [
+ "The output should state that cumulative external history exceeds 1 MiB using moderate records, not one oversized durable request.",
+ "The output should show a bounded provider-supplied model-history window.",
+ "The output should show that the marker and the same logical external history reference survive a host restart.",
+ "The output should not contain error messages or stack traces.",
+ ],
+ },
];
public static IReadOnlyList AzureFunctions { get; } =
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/08_FoundryManagedAgent.csproj b/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/08_FoundryManagedAgent.csproj
new file mode 100644
index 0000000..97c0e17
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/08_FoundryManagedAgent.csproj
@@ -0,0 +1,29 @@
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ FoundryManagedAgent
+ FoundryManagedAgent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/Program.cs b/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/Program.cs
new file mode 100644
index 0000000..fcfeaa8
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/Program.cs
@@ -0,0 +1,198 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Azure.AI.Projects;
+using Azure.AI.Projects.Agents;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.DurableTask;
+using Microsoft.Agents.AI.Foundry;
+using Microsoft.DurableTask.Client.AzureManaged;
+using Microsoft.DurableTask.Worker.AzureManaged;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+const string FoundryServiceHistoryProviderKey = "foundry-managed-service.v1";
+
+if (!IsExperimentalMailboxRuntimeAvailable())
+{
+ Console.Error.WriteLine(
+ "DRAFT SAMPLE: schema 2 mailbox writes are protected by an internal, default-disabled rollout gate. " +
+ "This sample cannot run against production or mixed-language runtimes yet.");
+ return 2;
+}
+
+string projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
+ ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
+string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL")
+ ?? throw new InvalidOperationException("FOUNDRY_MODEL is not set.");
+string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
+ ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set.");
+
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+DefaultAzureCredential credential = new();
+AIProjectClient projectClient = new(new Uri(projectEndpoint), credential);
+
+string agentName = $"durable-foundry-{Guid.NewGuid():N}"[..24];
+ProjectsAgentVersion? createdVersion = null;
+IHost? host = null;
+int exitCode = 0;
+
+try
+{
+ createdVersion = await projectClient.AgentAdministrationClient.CreateAgentVersionAsync(
+ agentName,
+ new ProjectsAgentVersionCreationOptions(
+ new DeclarativeAgentDefinition(deploymentName)
+ {
+ Instructions =
+ """
+ You are a conversation continuity test agent.
+ When asked to remember a marker, acknowledge it and retain it.
+ When later asked for the marker only, respond with exactly that marker and no other text.
+ """,
+ }));
+
+ FoundryAgent foundryAgent = CreateFoundryAgent(projectClient, createdVersion);
+ host = CreateHost(foundryAgent, dtsConnectionString);
+ await host.StartAsync();
+
+ AIAgent durableAgent = host.Services.GetRequiredKeyedService(foundryAgent.Name!);
+ DurableAgentSession durableSession =
+ (DurableAgentSession)await durableAgent.CreateSessionAsync();
+
+ string marker = $"durable-{Guid.NewGuid():N}"[..16];
+ Console.WriteLine("=== Durable Foundry Managed Agent Sample ===");
+ Console.WriteLine($"Foundry agent version: {createdVersion.Name}/{createdVersion.Version}");
+ Console.WriteLine($"Durable session: {durableSession.GetService()}");
+
+ AgentResponse first = await durableAgent.RunAsync(
+ $"Remember this marker for our conversation: {marker}. Confirm the marker in your reply.",
+ durableSession);
+ Console.WriteLine($"First response: {first.Text}");
+
+ if (!first.Text.Contains(marker, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("The first response did not confirm the generated marker.");
+ }
+
+ IHost firstHost = host;
+ host = null;
+ await StopAndDisposeHostAsync(firstHost);
+ Console.WriteLine("Host stopped. Starting a new host to force durable state restoration.");
+
+ FoundryAgent restoredFoundryAgent = CreateFoundryAgent(projectClient, createdVersion);
+ host = CreateHost(restoredFoundryAgent, dtsConnectionString);
+ await host.StartAsync();
+
+ AIAgent restoredDurableAgent =
+ host.Services.GetRequiredKeyedService(restoredFoundryAgent.Name!);
+ AgentResponse second = await restoredDurableAgent.RunAsync(
+ "Return the marker only.",
+ durableSession);
+ Console.WriteLine($"Second response: {second.Text}");
+
+ if (!second.Text.Contains(marker, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "Marker recall check failed: the second response did not contain the generated marker.");
+ }
+
+ Console.WriteLine("Marker recall after durable restart: PASS");
+ Console.WriteLine(
+ "The fixed service-owner binding and restored service conversation identity are verified by deterministic tests.");
+}
+catch (Exception ex)
+{
+ exitCode = 1;
+ Console.Error.WriteLine($"Sample failed: {ex}");
+}
+finally
+{
+ if (host is not null)
+ {
+ try
+ {
+ await StopAndDisposeHostAsync(host);
+ }
+ catch (Exception ex)
+ {
+ exitCode = 1;
+ Console.Error.WriteLine($"Failed to stop the durable host cleanly: {ex.Message}");
+ }
+ }
+
+ if (createdVersion is not null)
+ {
+ try
+ {
+ await projectClient.AgentAdministrationClient.DeleteAgentVersionAsync(
+ createdVersion.Name,
+ createdVersion.Version);
+ Console.WriteLine($"Deleted Foundry agent version: {createdVersion.Name}/{createdVersion.Version}");
+ }
+ catch (Exception ex)
+ {
+ exitCode = 1;
+ Console.Error.WriteLine(
+ $"Failed to delete Foundry agent version {createdVersion.Name}/{createdVersion.Version}: {ex.Message}");
+ }
+ }
+}
+
+return exitCode;
+
+static FoundryAgent CreateFoundryAgent(
+ AIProjectClient projectClient,
+ ProjectsAgentVersion agentVersion)
+{
+ FoundryAgent agent = projectClient.AsAIAgent(agentVersion);
+
+ _ = agent.GetService()
+ ?? throw new InvalidOperationException(
+ "The FoundryAgent did not expose its inner ChatClientAgent pipeline.");
+
+ return agent;
+}
+
+static IHost CreateHost(AIAgent foundryAgent, string dtsConnectionString)
+{
+ return Host.CreateDefaultBuilder()
+ .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
+ .ConfigureServices(services =>
+ {
+ services.ConfigureDurableAgents(
+ options =>
+ {
+ options.AddAIAgent(foundryAgent, timeToLive: TimeSpan.FromHours(1));
+ options.SetHistoryProviderKey(
+ foundryAgent.Name!,
+ FoundryServiceHistoryProviderKey);
+ options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;
+
+ // FoundryAgent's versioned-agent path does not enable
+ // RequirePerServiceCallChatHistoryPersistence. Once the first response supplies
+ // a service conversation ID, this C# runtime binds the durable session to the
+ // logical Foundry service owner and restores that opaque continuation on restart.
+ },
+ workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
+ clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
+ })
+ .Build();
+}
+
+static bool IsExperimentalMailboxRuntimeAvailable() => false;
+
+static async Task StopAndDisposeHostAsync(IHost host)
+{
+ try
+ {
+ await host.StopAsync();
+ }
+ finally
+ {
+ host.Dispose();
+ }
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/README.md b/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/README.md
new file mode 100644
index 0000000..b18b65f
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/README.md
@@ -0,0 +1,91 @@
+# Durable Foundry Managed Agent
+
+This sample wraps a server-managed, versioned Microsoft Foundry agent as a Microsoft Agent Framework `FoundryAgent`, registers it as a durable `AIAgent`, and restores its opaque service continuation across a host restart.
+
+> [!WARNING]
+> This is a draft, experimental sample. Schema 2 mailbox writes remain behind an internal,
+> default-disabled C# runtime gate, so the executable exits before creating any Foundry resource.
+> Do not enable this scenario in mixed-runtime production until Python, the dashboard, pollers, and
+> every other reader meet the schema 2 rollout floor.
+
+## Architecture
+
+```text
+Console application
+ |
+ | keyed AIAgent proxy + DurableAgentSession
+ v
+Durable Task Scheduler
+ |-- owns invocation, retry, durable entity state, and entity lifetime
+ |-- binds the durable session to a stable logical service-owner key
+ |-- persists execution/delivery records and the opaque inner AgentSession
+ v
+FoundryAgent -> ChatClientAgent -> Foundry Responses API
+ |-- owns the versioned agent definition
+ `-- owns the server-side conversation and its transcript
+```
+
+The application:
+
+1. Creates a uniquely named Foundry agent version with `CreateAgentVersionAsync`.
+2. Wraps that exact version with `AIProjectClient.AsAIAgent`, producing a `FoundryAgent`.
+3. Registers the `FoundryAgent` with `ConfigureDurableAgents`, keeps `HistoryRetentionMode.KeepAll`, and sets the stable logical key `foundry-managed-service.v1`. Deterministic tests activate schema 2 through the runtime's internal test hook; the sample does not expose or bypass that gate.
+4. Resolves the keyed durable `AIAgent` proxy and creates a `DurableAgentSession`.
+5. Sends a generated marker on the first turn. Foundry establishes the service conversation, and the durable entity persists the inner `ChatClientAgent` session containing that conversation identity.
+6. Stops the host, creates a fresh `FoundryAgent` wrapper and host, and sends a second turn using the same `DurableAgentSession`. The durable entity restores the opaque inner session. The deterministic registration test verifies that the same service conversation ID survives serialization and restoration; the live marker recall is an additional smoke signal, not the proof of identity.
+7. Stops the host before deleting the exact server agent version in `finally`.
+
+The host restart is intentional: it demonstrates that continuity comes from durable entity state, not from an in-memory `FoundryAgent` or `AgentSession`.
+
+## History ownership
+
+After Foundry establishes a conversation, Foundry owns the transcript. The durable entity keeps the fixed logical owner binding, execution/delivery records, and opaque inner-session continuation. It does not keep or replay a duplicate transcript in `conversationHistory`.
+
+Schema 2 terminal results and completion receipts are required because the service-owned transcript is not mirrored into durable state. Activation is deliberately unavailable through the public options API. The deterministic tests use the existing internal test hook, while this sample remains gated and retains `HistoryRetentionMode.KeepAll` rather than opting into automatic transcript eviction.
+
+This fixed ownership is an intentional C# durable runtime contract. `HistorySampleRegistrationTests.FoundryAgentRegistrationRestoresServiceContinuationAcrossProxyRestartAsync` invokes the actual versioned `FoundryAgent` through the registered durable proxy and verifies the entity-to-service transition, mailbox-only state, cold-host continuation, and a second call without credentials or network access. `FoundryAgentRegistrationTests.ServerManagedFoundryAgentRestoresFixedServiceOwnershipAsync` separately verifies wrapper discovery and the stable provider key. `AgentEntityHistoryTests.ServiceManagedConversationStoresOnlyMailboxAndContinuationAsync`, `AgentEntityHistoryTests.FirstServiceManagedTurnDoesNotLeaveEntityOwnedTranscriptAsync`, and `AgentEntityHistoryTests.WrappedServerManagedSessionSurvivesColdEntityInvocationAsync` cover the durable entity state shape and cold continuation.
+
+This versioned `FoundryAgent` path does **not** enable `ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence`, so the sample intentionally does not call `SetServiceManagedPerServiceCallHistory`. That declaration is only needed when a service-backed `ChatClientAgent` explicitly enables per-service-call persistence; it is otherwise ignored.
+
+For arbitrary server-hosted `AIAgent` implementations that do not expose a discoverable `ChatClientAgent` pipeline, configure `DurableAgentHistoryReplayMode.CurrentRequestOnly` explicitly. This sample does not need that fallback because `FoundryAgent` exposes its inner `ChatClientAgent` through `GetService()`.
+
+## Prerequisites and configuration
+
+- .NET 10 SDK
+- Azure CLI authenticated with `az login`
+- Permission to create, invoke, and delete agents in the Foundry project
+- A Durable Task Scheduler endpoint, such as the local DTS emulator
+
+Set:
+
+```powershell
+$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"
+$env:FOUNDRY_MODEL = ""
+$env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
+```
+
+Under this sample's model policy, configure an OpenAI model deployment that is supported by Foundry prompt agents and the Responses API. No credentials are stored in tracked files. `DefaultAzureCredential` is convenient for local development; production applications should prefer a specific credential such as `ManagedIdentityCredential` to avoid unintended credential probing and latency.
+
+## Run
+
+The source is retained as the intended end-to-end flow, but it is not currently runnable. Invoking it
+prints the draft-gate message and exits before reading credentials, creating a Foundry agent version,
+or contacting DTS:
+
+```powershell
+cd dotnet\samples\DurableAgents\ConsoleApps\08_FoundryManagedAgent
+dotnet run --framework net10.0
+```
+
+Once the cross-runtime rollout floor is met and the production gate is approved, a successful run is
+expected to print both responses and `Marker recall after durable restart: PASS`. Model wording is
+nondeterministic. The marker check is only a smoke signal; the deterministic tests prove the fixed
+binding and restored service conversation ID.
+
+## Cleanup
+
+The durable host is stopped before cleanup. Because the sample creates a unique server agent name, its `finally` block deletes only the exact version it created. Cleanup failures are reported and cause a failed process exit when there was no earlier failure; they never hide the original sample failure.
+
+## Known limitation
+
+A caller still cannot seed a new `DurableAgentSession` from an existing, pre-durable Foundry conversation. That unsupported scenario is isolated in the [server-managed agent reproduction branch](https://github.com/microsoft/agent-framework-durable-extension/tree/tamirdresher-microsoft-server-managed-agent-repro).
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/09_CustomHistoryProvider.csproj b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/09_CustomHistoryProvider.csproj
new file mode 100644
index 0000000..cb99e5c
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/09_CustomHistoryProvider.csproj
@@ -0,0 +1,30 @@
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ CustomHistoryProvider
+ CustomHistoryProvider
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/JsonFileChatHistoryProvider.cs b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/JsonFileChatHistoryProvider.cs
new file mode 100644
index 0000000..c1dfe9b
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/JsonFileChatHistoryProvider.cs
@@ -0,0 +1,423 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Concurrent;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace CustomHistoryProvider;
+
+///
+/// Stores a text-only chat transcript in sample-local JSON files.
+///
+public sealed class JsonFileChatHistoryProvider : ChatHistoryProvider, IDisposable
+{
+ public const string ProviderKey = "sample-json-file-history.v1";
+
+ public const string StateKey = "sample-json-history";
+
+ private const int MaxSeedMessageUtf8Bytes = 8 * 1024;
+
+ private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = true,
+ };
+
+ private readonly string _storeDirectory;
+ private readonly int _maxModelMessages;
+ private readonly int _maxModelTextUtf8Bytes;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private readonly ConcurrentDictionary _observedHistoryIds = new(StringComparer.Ordinal);
+ private readonly Dictionary _lastModelWindows = new(StringComparer.Ordinal);
+
+ public JsonFileChatHistoryProvider(
+ string storeDirectory,
+ int maxModelMessages = 12,
+ int maxModelTextUtf8Bytes = 32 * 1024)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(storeDirectory);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxModelMessages);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxModelTextUtf8Bytes);
+
+ this._storeDirectory = Path.GetFullPath(storeDirectory);
+ this._maxModelMessages = maxModelMessages;
+ this._maxModelTextUtf8Bytes = maxModelTextUtf8Bytes;
+ }
+
+ public override IReadOnlyList StateKeys => [StateKey];
+
+ public IReadOnlyList GetObservedHistoryIds() =>
+ this._observedHistoryIds.Keys.Order(StringComparer.Ordinal).ToArray();
+
+ public string GetHistoryId(AgentSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ return this.GetOrCreateReference(session).FileName;
+ }
+
+ public async Task> ReadMessagesAsync(
+ AgentSession session,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ HistoryReference reference = this.GetOrCreateReference(session);
+
+ await this._gate.WaitAsync(cancellationToken);
+ try
+ {
+ List stored =
+ await this.ReadStoredMessagesAsync(reference, cancellationToken);
+ return stored.ConvertAll(ToChatMessage);
+ }
+ finally
+ {
+ this._gate.Release();
+ }
+ }
+
+ public async Task SeedHistoryAsync(
+ string historyId,
+ long minimumStoreBytes,
+ int messageTextUtf8Bytes,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minimumStoreBytes);
+ if (messageTextUtf8Bytes is < 256 or > MaxSeedMessageUtf8Bytes)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(messageTextUtf8Bytes),
+ $"Seed messages must be between 256 and {MaxSeedMessageUtf8Bytes} UTF-8 bytes.");
+ }
+
+ HistoryReference reference = this.GetReference(historyId);
+
+ await this._gate.WaitAsync(cancellationToken);
+ try
+ {
+ List stored =
+ await this.ReadStoredMessagesAsync(reference, cancellationToken);
+ int initialCount = stored.Count;
+ int seedIndex = initialCount;
+ long currentBytes = this.GetPersistedBytes(reference);
+
+ while (currentBytes <= minimumStoreBytes)
+ {
+ int remainingMessages = Math.Max(
+ 1,
+ checked((int)Math.Ceiling(
+ (minimumStoreBytes - currentBytes + 1d) / messageTextUtf8Bytes)));
+
+ for (int index = 0; index < remainingMessages; index++)
+ {
+ ChatRole role = seedIndex % 2 == 0 ? ChatRole.User : ChatRole.Assistant;
+ stored.Add(CreateSeedMessage(seedIndex++, role, messageTextUtf8Bytes));
+ }
+
+ await this.WriteStoredMessagesAsync(reference, stored, cancellationToken);
+ currentBytes = this.GetPersistedBytes(reference);
+ }
+
+ return new SeedHistoryResult(
+ SeededMessageCount: stored.Count - initialCount,
+ PersistedMessageCount: stored.Count,
+ PersistedBytes: currentBytes,
+ MaximumSeedMessageTextUtf8Bytes: messageTextUtf8Bytes);
+ }
+ finally
+ {
+ this._gate.Release();
+ }
+ }
+
+ public async Task GetStatisticsAsync(
+ string historyId,
+ CancellationToken cancellationToken = default)
+ {
+ HistoryReference reference = this.GetReference(historyId);
+
+ await this._gate.WaitAsync(cancellationToken);
+ try
+ {
+ List stored =
+ await this.ReadStoredMessagesAsync(reference, cancellationToken);
+ this._lastModelWindows.TryGetValue(historyId, out ModelWindowStatistics? window);
+
+ return new HistoryStatistics(
+ HistoryId: historyId,
+ PersistedMessageCount: stored.Count,
+ PersistedBytes: this.GetPersistedBytes(reference),
+ LastModelWindow: window);
+ }
+ finally
+ {
+ this._gate.Release();
+ }
+ }
+
+ protected override async ValueTask> ProvideChatHistoryAsync(
+ InvokingContext context,
+ CancellationToken cancellationToken = default)
+ {
+ AgentSession session = context.Session ??
+ throw new InvalidOperationException("A session is required for file-backed history.");
+ HistoryReference reference = this.GetOrCreateReference(session);
+
+ await this._gate.WaitAsync(cancellationToken);
+ try
+ {
+ List stored =
+ await this.ReadStoredMessagesAsync(reference, cancellationToken);
+ List modelWindow = this.SelectModelWindow(stored);
+ this._lastModelWindows[reference.FileName] = new ModelWindowStatistics(
+ modelWindow.Count,
+ modelWindow.Sum(GetTextUtf8Bytes));
+ return modelWindow.ConvertAll(ToChatMessage);
+ }
+ finally
+ {
+ this._gate.Release();
+ }
+ }
+
+ protected override async ValueTask StoreChatHistoryAsync(
+ InvokedContext context,
+ CancellationToken cancellationToken = default)
+ {
+ AgentSession session = context.Session ??
+ throw new InvalidOperationException("A session is required for file-backed history.");
+ HistoryReference reference = this.GetOrCreateReference(session);
+
+ await this._gate.WaitAsync(cancellationToken);
+ try
+ {
+ List stored =
+ await this.ReadStoredMessagesAsync(reference, cancellationToken);
+ stored.AddRange(context.RequestMessages.Select(ToStoredMessage));
+ stored.AddRange((context.ResponseMessages ?? []).Select(ToStoredMessage));
+
+ await this.WriteStoredMessagesAsync(reference, stored, cancellationToken);
+ }
+ finally
+ {
+ this._gate.Release();
+ }
+ }
+
+ private HistoryReference GetOrCreateReference(AgentSession session)
+ {
+ HistoryReference? reference = session.StateBag.GetValue(StateKey);
+ if (reference is null)
+ {
+ reference = new HistoryReference
+ {
+ FileName = $"{Guid.NewGuid():N}.json",
+ };
+ session.StateBag.SetValue(StateKey, reference);
+ }
+
+ this.ValidateHistoryId(reference.FileName);
+ this._observedHistoryIds.TryAdd(reference.FileName, 0);
+ return reference;
+ }
+
+ private static StoredChatMessage ToStoredMessage(ChatMessage message)
+ {
+ if (message.Contents.Any(content => content is not TextContent))
+ {
+ throw new NotSupportedException(
+ "This sample provider intentionally supports text content only. " +
+ "A production provider must serialize every content type used by the agent.");
+ }
+
+ return new StoredChatMessage
+ {
+ Role = message.Role.Value,
+ Text = message.Text,
+ MessageId = message.MessageId,
+ CreatedAt = message.CreatedAt,
+ };
+ }
+
+ private static ChatMessage ToChatMessage(StoredChatMessage message) =>
+ new(ToChatRole(message.Role), message.Text)
+ {
+ MessageId = message.MessageId,
+ CreatedAt = message.CreatedAt,
+ };
+
+ private static ChatRole ToChatRole(string role) =>
+ role switch
+ {
+ "assistant" => ChatRole.Assistant,
+ "system" => ChatRole.System,
+ "tool" => ChatRole.Tool,
+ "user" => ChatRole.User,
+ _ => throw new JsonException($"Unsupported chat role '{role}'."),
+ };
+
+ private static StoredChatMessage CreateSeedMessage(
+ int index,
+ ChatRole role,
+ int messageTextUtf8Bytes)
+ {
+ string prefix = $"[SIMULATED OLD HISTORY {index:D6} {role.Value}] ";
+ if (Encoding.UTF8.GetByteCount(prefix) > messageTextUtf8Bytes)
+ {
+ throw new ArgumentOutOfRangeException(nameof(messageTextUtf8Bytes));
+ }
+
+ string text = prefix + new string(
+ (char)('a' + (index % 26)),
+ messageTextUtf8Bytes - prefix.Length);
+ return new StoredChatMessage
+ {
+ Role = role.Value,
+ Text = text,
+ MessageId = $"simulated-old-history-{index:D6}",
+ CreatedAt = DateTimeOffset.UnixEpoch.AddSeconds(index),
+ };
+ }
+
+ private static int GetTextUtf8Bytes(StoredChatMessage message) =>
+ Encoding.UTF8.GetByteCount(message.Text ?? string.Empty);
+
+ private List SelectModelWindow(IReadOnlyList stored)
+ {
+ List newestFirst = [];
+ int textBytes = 0;
+
+ for (int index = stored.Count - 1;
+ index >= 0 && newestFirst.Count < this._maxModelMessages;
+ index--)
+ {
+ int messageBytes = GetTextUtf8Bytes(stored[index]);
+ if (textBytes + messageBytes > this._maxModelTextUtf8Bytes)
+ {
+ break;
+ }
+
+ newestFirst.Add(stored[index]);
+ textBytes += messageBytes;
+ }
+
+ newestFirst.Reverse();
+ return newestFirst;
+ }
+
+ private async Task> ReadStoredMessagesAsync(
+ HistoryReference reference,
+ CancellationToken cancellationToken)
+ {
+ string path = this.GetHistoryPath(reference);
+ if (!File.Exists(path))
+ {
+ return [];
+ }
+
+ await using FileStream stream = File.OpenRead(path);
+ return await JsonSerializer.DeserializeAsync>(
+ stream,
+ s_jsonOptions,
+ cancellationToken)
+ ?? [];
+ }
+
+ private async Task WriteStoredMessagesAsync(
+ HistoryReference reference,
+ List messages,
+ CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(this._storeDirectory);
+ string path = this.GetHistoryPath(reference);
+ string temporaryPath = Path.Combine(
+ this._storeDirectory,
+ $"{reference.FileName}.{Guid.NewGuid():N}.writing");
+
+ try
+ {
+ await using (FileStream stream = new(
+ temporaryPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.Asynchronous | FileOptions.WriteThrough))
+ {
+ await JsonSerializer.SerializeAsync(
+ stream,
+ messages,
+ s_jsonOptions,
+ cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+ }
+
+ File.Move(temporaryPath, path, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temporaryPath))
+ {
+ File.Delete(temporaryPath);
+ }
+ }
+ }
+
+ private HistoryReference GetReference(string historyId)
+ {
+ this.ValidateHistoryId(historyId);
+ this._observedHistoryIds.TryAdd(historyId, 0);
+ return new HistoryReference { FileName = historyId };
+ }
+
+ private void ValidateHistoryId(string historyId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(historyId);
+ if (!string.Equals(Path.GetFileName(historyId), historyId, StringComparison.Ordinal) ||
+ !historyId.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("History IDs must be JSON file names.", nameof(historyId));
+ }
+ }
+
+ private long GetPersistedBytes(HistoryReference reference)
+ {
+ string path = this.GetHistoryPath(reference);
+ return File.Exists(path) ? new FileInfo(path).Length : 0;
+ }
+
+ private string GetHistoryPath(HistoryReference reference) =>
+ Path.Combine(this._storeDirectory, Path.GetFileName(reference.FileName));
+
+ public void Dispose() => this._gate.Dispose();
+
+ public sealed class HistoryReference
+ {
+ public string FileName { get; set; } = string.Empty;
+ }
+
+ public sealed record SeedHistoryResult(
+ int SeededMessageCount,
+ int PersistedMessageCount,
+ long PersistedBytes,
+ int MaximumSeedMessageTextUtf8Bytes);
+
+ public sealed record ModelWindowStatistics(int MessageCount, int TextUtf8Bytes);
+
+ public sealed record HistoryStatistics(
+ string HistoryId,
+ int PersistedMessageCount,
+ long PersistedBytes,
+ ModelWindowStatistics? LastModelWindow);
+
+ private sealed class StoredChatMessage
+ {
+ public string Role { get; set; } = string.Empty;
+
+ public string? Text { get; set; }
+
+ public string? MessageId { get; set; }
+
+ public DateTimeOffset? CreatedAt { get; set; }
+ }
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/MarkerInput.cs b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/MarkerInput.cs
new file mode 100644
index 0000000..1d574c5
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/MarkerInput.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+namespace CustomHistoryProvider;
+
+///
+/// Validates the short marker used by the custom history provider sample.
+///
+public static class MarkerInput
+{
+ ///
+ /// Maximum marker size in UTF-8 bytes.
+ ///
+ public const int MaximumUtf8Bytes = 1024;
+
+ private static readonly Encoding s_strictUtf8 = new UTF8Encoding(
+ encoderShouldEmitUTF8Identifier: false,
+ throwOnInvalidBytes: true);
+
+ ///
+ /// Checks whether a marker is present, valid Unicode, and within the sample's byte limit.
+ ///
+ public static bool TryValidate(
+ [NotNullWhen(true)] string? marker,
+ out string errorMessage)
+ {
+ if (string.IsNullOrWhiteSpace(marker))
+ {
+ errorMessage = "A marker is required.";
+ return false;
+ }
+
+ int markerBytes;
+ try
+ {
+ markerBytes = s_strictUtf8.GetByteCount(marker);
+ }
+ catch (EncoderFallbackException)
+ {
+ errorMessage = "The marker must contain valid Unicode text.";
+ return false;
+ }
+
+ if (markerBytes > MaximumUtf8Bytes)
+ {
+ errorMessage =
+ $"The marker must be at most {MaximumUtf8Bytes} UTF-8 bytes; received {markerBytes}.";
+ return false;
+ }
+
+ errorMessage = string.Empty;
+ return true;
+ }
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/Program.cs b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/Program.cs
new file mode 100644
index 0000000..f108bdb
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/Program.cs
@@ -0,0 +1,214 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using CustomHistoryProvider;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.DurableTask;
+using Microsoft.DurableTask.Client.AzureManaged;
+using Microsoft.DurableTask.Worker.AzureManaged;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenAI.Chat;
+
+const string AgentName = "HistoryAgent";
+const long OneMiB = 1_048_576;
+const int SeedMessageBytes = 4 * 1024;
+const int ModelHistoryMessageLimit = 12;
+const int ModelHistoryByteLimit = 32 * 1024;
+
+if (!IsExperimentalMailboxRuntimeAvailable())
+{
+ Console.Error.WriteLine(
+ "DRAFT SAMPLE: schema 2 mailbox writes are protected by an internal, default-disabled rollout gate. " +
+ "This sample cannot run against production or mixed-language runtimes yet.");
+ Environment.ExitCode = 2;
+ return;
+}
+
+// Get the Foundry project endpoint and model deployment name from environment variables.
+string projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
+ ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
+string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL")
+ ?? throw new InvalidOperationException("FOUNDRY_MODEL is not set.");
+
+// The Azure OpenAI endpoint is the authority (scheme + host) of the Foundry project endpoint.
+string endpoint = new Uri(projectEndpoint).GetLeftPart(UriPartial.Authority);
+
+// Get DTS connection string from environment variable
+string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
+ ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
+
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+AzureOpenAIClient client = new(new Uri(endpoint), new DefaultAzureCredential());
+
+Console.ForegroundColor = ConsoleColor.Cyan;
+Console.WriteLine("=== Custom External History Provider Sample ===");
+Console.ResetColor();
+Console.WriteLine(
+ "Enter a short marker for the durable agent to remember " +
+ $"(up to {MarkerInput.MaximumUtf8Bytes} UTF-8 bytes):");
+Console.WriteLine();
+
+Console.ForegroundColor = ConsoleColor.Yellow;
+Console.Write("Marker: ");
+Console.ResetColor();
+string? marker = Console.ReadLine();
+if (!MarkerInput.TryValidate(marker, out string markerError))
+{
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.Error.WriteLine($"Error: {markerError}");
+ Console.ResetColor();
+ Environment.ExitCode = 1;
+ return;
+}
+
+string storeDirectory = Path.Combine(
+ AppContext.BaseDirectory,
+ ".sample-history",
+ Guid.NewGuid().ToString("N"));
+Directory.CreateDirectory(storeDirectory);
+
+try
+{
+ JsonElement serializedSession;
+ string historyId;
+
+ using (IHost firstHost = CreateHost(
+ client,
+ deploymentName,
+ dtsConnectionString,
+ storeDirectory,
+ out JsonFileChatHistoryProvider firstProvider))
+ {
+ await firstHost.StartAsync();
+ AIAgent agent = firstHost.Services.GetRequiredKeyedService(AgentName);
+ AgentSession session = await agent.CreateSessionAsync();
+
+ _ = await agent.RunAsync("Reply with READY only.", session);
+ historyId = firstProvider.GetObservedHistoryIds().Single();
+
+ JsonFileChatHistoryProvider.SeedHistoryResult seeded = await firstProvider.SeedHistoryAsync(
+ historyId,
+ minimumStoreBytes: OneMiB,
+ messageTextUtf8Bytes: SeedMessageBytes);
+
+ Console.WriteLine();
+ Console.WriteLine(
+ $"External history: {seeded.PersistedBytes:N0} bytes in " +
+ $"{seeded.PersistedMessageCount:N0} moderate records.");
+ Console.WriteLine(
+ "The size is cumulative; no individual durable request approaches the 1 MiB DTS boundary.");
+
+ AgentResponse response = await agent.RunAsync(
+ $"Remember this exact marker: {marker}. Reply with the marker only.",
+ session);
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine($"HistoryAgent: {response.Text}");
+ Console.ResetColor();
+
+ JsonFileChatHistoryProvider.HistoryStatistics statistics =
+ await firstProvider.GetStatisticsAsync(historyId);
+ WriteModelWindow(statistics);
+ serializedSession = await agent.SerializeSessionAsync(session);
+ await firstHost.StopAsync();
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("Restarting the host and restoring the same durable session...");
+
+ using (IHost secondHost = CreateHost(
+ client,
+ deploymentName,
+ dtsConnectionString,
+ storeDirectory,
+ out JsonFileChatHistoryProvider secondProvider))
+ {
+ await secondHost.StartAsync();
+ AIAgent agent = secondHost.Services.GetRequiredKeyedService(AgentName);
+ AgentSession session = await agent.DeserializeSessionAsync(serializedSession);
+ AgentResponse response = await agent.RunAsync(
+ "What exact marker did I ask you to remember?",
+ session);
+
+ if (!response.Text.Contains(marker, StringComparison.Ordinal) ||
+ secondProvider.GetObservedHistoryIds().Single() != historyId)
+ {
+ throw new InvalidOperationException("The marker or external history identity was not restored.");
+ }
+
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine($"HistoryAgent after restart: {response.Text}");
+ Console.WriteLine(
+ "Marker recall and the logical external history reference were preserved after restart.");
+ Console.ResetColor();
+
+ JsonFileChatHistoryProvider.HistoryStatistics statistics =
+ await secondProvider.GetStatisticsAsync(historyId);
+ WriteModelWindow(statistics);
+ await secondHost.StopAsync();
+ }
+}
+finally
+{
+ Directory.Delete(storeDirectory, recursive: true);
+}
+
+static void WriteModelWindow(JsonFileChatHistoryProvider.HistoryStatistics statistics)
+{
+ JsonFileChatHistoryProvider.ModelWindowStatistics? window = statistics.LastModelWindow;
+ Console.WriteLine(
+ $"Provider-supplied model history: {window?.MessageCount ?? 0} records, " +
+ $"{window?.TextUtf8Bytes ?? 0:N0} UTF-8 text bytes " +
+ $"(limits: {ModelHistoryMessageLimit} records / {ModelHistoryByteLimit:N0} bytes).");
+}
+
+static IHost CreateHost(
+ AzureOpenAIClient client,
+ string deploymentName,
+ string dtsConnectionString,
+ string storeDirectory,
+ out JsonFileChatHistoryProvider historyProvider)
+{
+ historyProvider = new JsonFileChatHistoryProvider(
+ storeDirectory,
+ maxModelMessages: ModelHistoryMessageLimit,
+ maxModelTextUtf8Bytes: ModelHistoryByteLimit);
+ JsonFileChatHistoryProvider registeredProvider = historyProvider;
+ AIAgent agent = client.GetChatClient(deploymentName).AsAIAgent(
+ new ChatClientAgentOptions
+ {
+ Name = AgentName,
+ ChatOptions = new()
+ {
+ Instructions =
+ "Remember exact markers when asked, reproduce them exactly, and keep responses concise.",
+ },
+ ChatHistoryProvider = registeredProvider,
+ });
+
+ return Host.CreateDefaultBuilder()
+ .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(registeredProvider);
+ services.ConfigureDurableAgents(
+ options =>
+ {
+ options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1));
+ options.SetHistoryProviderKey(
+ AgentName,
+ JsonFileChatHistoryProvider.ProviderKey);
+ options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;
+ },
+ workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
+ clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
+ })
+ .Build();
+}
+
+static bool IsExperimentalMailboxRuntimeAvailable() => false;
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/README.md b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/README.md
new file mode 100644
index 0000000..cef9e1e
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/README.md
@@ -0,0 +1,80 @@
+# Custom History Provider Sample
+
+This sample demonstrates a durable Azure OpenAI agent whose cumulative conversation history is
+stored by a custom JSON-file `ChatHistoryProvider`.
+
+> [!WARNING]
+> This is a draft, experimental sample. Schema 2 mailbox writes remain behind an internal,
+> default-disabled C# runtime gate, so the executable exits before reading credentials or contacting
+> the model or DTS. Do not enable this scenario in mixed-runtime production until Python, the
+> dashboard, pollers, and every other reader meet the schema 2 rollout floor.
+
+## Key Concepts Demonstrated
+
+- Persisting the full transcript outside the durable entity.
+- Binding the durable session to the stable logical provider key `sample-json-file-history.v1`.
+- Restoring the same external history reference with a compatible provider instance after a host restart.
+- Growing external history beyond 1 MiB with many simulated 4 KiB records.
+- Projecting only the newest 12 records, capped at 32 KiB of UTF-8 text, into model history.
+
+The sample limits the user-provided marker to 1,024 UTF-8 bytes before it creates a durable session
+or sends a request. The 1 KiB marker limit is intentionally smaller than each simulated 4 KiB
+history record and leaves conservative space for prompt and serialization overhead, so the sample
+does not send one message larger than 1 MiB. A single oversized request or tool result can exceed
+the Durable Task Scheduler message boundary before the provider can process it. The seeded records
+represent ordinary conversation accumulated over time.
+
+The durable entity retains execution/delivery records, the fixed provider binding, and the opaque
+provider continuation/reference. It does not keep a second external-owned request/response transcript
+in `conversationHistory`, and mailbox results are not replayed into model input. External provider
+writes are not transactionally coupled to the durable entity commit.
+
+External ownership requires schema 2 terminal results and completion receipts, but activation is
+deliberately unavailable through the public options API. Deterministic tests use the existing
+internal test hook; the sample itself does not expose or bypass that gate. It separately keeps
+`HistoryRetentionMode.KeepAll`, so mailbox activation remains independent of automatic retention.
+
+This JSON implementation reads the full file before selecting the bounded model-history suffix.
+It is text-focused, local to one process/filesystem, and not a distributed, idempotent, exactly-once,
+claim-check, or payload-offload adapter. Production stores need their own concurrency, paging,
+indexing, retry, item-size, retention, transaction, and eviction design, and must serialize every
+content type and message property the application uses.
+
+This is a sample storage provider, not a production design.
+
+## Environment Setup
+
+See the [README.md](../README.md) file in the parent directory for Foundry, authentication, and
+Durable Task Scheduler setup.
+
+## Running the Sample
+
+```bash
+cd dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider
+dotnet run --framework net10.0
+```
+
+The source retains the intended flow: enter a short marker, create more than 1 MiB of cumulative
+external history, ask the agent to remember the marker, restart the host with a compatible provider
+instance, verify marker recall and the same external history reference, and delete the exact
+sample-local JSON directory. Until the rollout gate is approved, invoking the executable prints the
+draft-gate message and exits before performing those operations.
+
+## Tests
+
+```bash
+dotnet test --project tests/09_CustomHistoryProvider.Tests.csproj
+```
+
+The sample-local tests cover marker validation at the 1,024-byte boundary (including oversized
+ASCII and multibyte input), the stable logical key, external storage above 1 MiB, bounded model-history
+projection, provider-reference restoration, framework-filtered persistence, unsupported content
+failure, and cancellation without requiring Foundry or DTS. The durable runtime registration tests
+exercise the configured keyed proxy across a cold host restart, verify schema 2 mailbox and completion
+state without a transcript mirror through the internal test hook, and verify a missing mailbox
+activation fails before provider or model callbacks. Additional durable runtime tests
+`AgentEntityHistoryTests.RecreatedExternalProviderWithSameLogicalKeyContinuesWithoutTranscriptMirrorAsync`,
+`AgentEntityHistoryTests.ChangedExternalProviderKeyRejectsBeforeProviderOrModelCallbacksAsync`,
+`AgentEntityHistoryTests.CustomProviderOwnsTranscriptAndEntityStoresOnlyMailboxAndContinuationAsync`,
+and the provider failure/cancellation tests cover cold entity restart, binding rejection, zero
+transcript mirroring, mailbox availability, replay exclusion, and commit isolation.
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/09_CustomHistoryProvider.Tests.csproj b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/09_CustomHistoryProvider.Tests.csproj
new file mode 100644
index 0000000..12f83fb
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/09_CustomHistoryProvider.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+ net10.0
+ false
+ true
+ false
+ Exe
+ enable
+ enable
+ $(NoWarn);xUnit1051
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/JsonFileChatHistoryProviderTests.cs b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/JsonFileChatHistoryProviderTests.cs
new file mode 100644
index 0000000..8f98524
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/JsonFileChatHistoryProviderTests.cs
@@ -0,0 +1,322 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text;
+using CustomHistoryProvider;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace CustomHistoryProviderTests;
+
+public sealed class JsonFileChatHistoryProviderTests
+{
+ [Fact]
+ public void LogicalProviderKeyIsAStableExplicitIdentifier()
+ {
+ Assert.Equal(
+ "sample-json-file-history.v1",
+ JsonFileChatHistoryProvider.ProviderKey);
+ }
+
+ [Fact]
+ public async Task HistoryRoundTripsThroughANewProviderInstanceAsync()
+ {
+ string directory = CreateStoreDirectory();
+ try
+ {
+ using JsonFileChatHistoryProvider firstProvider = new(
+ directory,
+ maxModelMessages: 8,
+ maxModelTextUtf8Bytes: 24 * 1024);
+ ChatClientAgent firstAgent = CreateAgent(firstProvider);
+ AgentSession firstSession = await firstAgent.CreateSessionAsync();
+ ChatMessage request = new(ChatRole.User, "first request");
+
+ IEnumerable firstInput = await firstProvider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(firstAgent, firstSession, [request]));
+ await firstProvider.InvokedAsync(
+ new ChatHistoryProvider.InvokedContext(
+ firstAgent,
+ firstSession,
+ firstInput,
+ [new ChatMessage(ChatRole.Assistant, "first response")]));
+ var serializedSession = await firstAgent.SerializeSessionAsync(firstSession);
+ string firstHistoryId = firstProvider.GetHistoryId(firstSession);
+
+ using JsonFileChatHistoryProvider secondProvider = new(
+ directory,
+ maxModelMessages: 8,
+ maxModelTextUtf8Bytes: 24 * 1024);
+ ChatClientAgent secondAgent = CreateAgent(secondProvider);
+ AgentSession restoredSession = await secondAgent.DeserializeSessionAsync(serializedSession);
+ IEnumerable restoredInput = await secondProvider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(
+ secondAgent,
+ restoredSession,
+ [new ChatMessage(ChatRole.User, "second request")]));
+
+ Assert.Equal(
+ ["first request", "first response", "second request"],
+ restoredInput.Select(message => message.Text));
+ Assert.Equal(firstHistoryId, secondProvider.GetHistoryId(restoredSession));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task StoreDoesNotDuplicateTheProvidedHistoryPrefixAsync()
+ {
+ string directory = CreateStoreDirectory();
+ try
+ {
+ using JsonFileChatHistoryProvider provider = new(directory);
+ ChatClientAgent agent = CreateAgent(provider);
+ AgentSession session = await agent.CreateSessionAsync();
+
+ IEnumerable firstInput = await provider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(
+ agent,
+ session,
+ [new ChatMessage(ChatRole.User, "first request")]));
+ await provider.InvokedAsync(
+ new ChatHistoryProvider.InvokedContext(
+ agent,
+ session,
+ firstInput,
+ [new ChatMessage(ChatRole.Assistant, "first response")]));
+
+ IEnumerable secondInput = await provider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(
+ agent,
+ session,
+ [new ChatMessage(ChatRole.User, "second request")]));
+ await provider.InvokedAsync(
+ new ChatHistoryProvider.InvokedContext(
+ agent,
+ session,
+ secondInput,
+ [new ChatMessage(ChatRole.Assistant, "second response")]));
+
+ IReadOnlyList stored = await provider.ReadMessagesAsync(session);
+ Assert.Equal(
+ ["first request", "first response", "second request", "second response"],
+ stored.Select(message => message.Text));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task StoreCanExceedOneMiBWhileModelHistoryRemainsBoundedAsync()
+ {
+ const long OneMiB = 1_048_576;
+ const int MessageTextBytes = 4 * 1024;
+ const int WindowMessages = 8;
+ const int WindowTextBytes = 24 * 1024;
+
+ string directory = CreateStoreDirectory();
+ try
+ {
+ using JsonFileChatHistoryProvider provider = new(
+ directory,
+ maxModelMessages: WindowMessages,
+ maxModelTextUtf8Bytes: WindowTextBytes);
+ ChatClientAgent agent = CreateAgent(provider);
+ AgentSession session = await agent.CreateSessionAsync();
+
+ IEnumerable bootstrapInput = await provider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(
+ agent,
+ session,
+ [new ChatMessage(ChatRole.User, "bootstrap")]));
+ await provider.InvokedAsync(
+ new ChatHistoryProvider.InvokedContext(
+ agent,
+ session,
+ bootstrapInput,
+ [new ChatMessage(ChatRole.Assistant, "ready")]));
+
+ string historyId = provider.GetHistoryId(session);
+ JsonFileChatHistoryProvider.SeedHistoryResult seed = await provider.SeedHistoryAsync(
+ historyId,
+ OneMiB,
+ MessageTextBytes);
+
+ IEnumerable projectedInput = await provider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(
+ agent,
+ session,
+ [new ChatMessage(ChatRole.User, "small current request")]));
+ List projectedMessages = projectedInput.ToList();
+ JsonFileChatHistoryProvider.HistoryStatistics statistics =
+ await provider.GetStatisticsAsync(historyId);
+ IReadOnlyList allMessages = await provider.ReadMessagesAsync(session);
+ var serializedSession = await agent.SerializeSessionAsync(session);
+
+ Assert.True(seed.PersistedBytes > OneMiB);
+ Assert.True(seed.SeededMessageCount > 100);
+ Assert.Equal(MessageTextBytes, seed.MaximumSeedMessageTextUtf8Bytes);
+ Assert.All(
+ allMessages.Where(message => message.MessageId?.StartsWith(
+ "simulated-old-history-",
+ StringComparison.Ordinal) is true),
+ message => Assert.Equal(MessageTextBytes, Encoding.UTF8.GetByteCount(message.Text!)));
+ Assert.Equal("small current request", projectedMessages[^1].Text);
+ Assert.NotNull(statistics.LastModelWindow);
+ Assert.Equal(statistics.LastModelWindow.MessageCount + 1, projectedMessages.Count);
+ Assert.True(statistics.LastModelWindow.MessageCount <= WindowMessages);
+ Assert.True(statistics.LastModelWindow.TextUtf8Bytes <= WindowTextBytes);
+ Assert.True(statistics.PersistedMessageCount > statistics.LastModelWindow.MessageCount);
+ Assert.DoesNotContain("SIMULATED OLD HISTORY", serializedSession.GetRawText());
+ Assert.True(Encoding.UTF8.GetByteCount(serializedSession.GetRawText()) < 4096);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task FrameworkFilteredHistoryIsNotStoredAgainAfterInvocationAsync()
+ {
+ string directory = CreateStoreDirectory();
+ try
+ {
+ using JsonFileChatHistoryProvider provider = new(
+ directory,
+ maxModelMessages: 4,
+ maxModelTextUtf8Bytes: 16 * 1024);
+ ChatClientAgent agent = CreateAgent(provider);
+ AgentSession session = await agent.CreateSessionAsync();
+ string historyId = provider.GetHistoryId(session);
+ await provider.SeedHistoryAsync(historyId, 32 * 1024, 1024);
+
+ int countBefore = (await provider.ReadMessagesAsync(session)).Count;
+ IEnumerable input = await provider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(
+ agent,
+ session,
+ [new ChatMessage(ChatRole.User, "new request")]));
+ await provider.InvokedAsync(
+ new ChatHistoryProvider.InvokedContext(
+ agent,
+ session,
+ input,
+ [new ChatMessage(ChatRole.Assistant, "new response")]));
+
+ IReadOnlyList stored = await provider.ReadMessagesAsync(session);
+ Assert.Equal(countBefore + 2, stored.Count);
+ Assert.Equal(["new request", "new response"], stored.TakeLast(2).Select(message => message.Text));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task UnsupportedContentFailsWithoutWritingAHistoryFileAsync()
+ {
+ string directory = CreateStoreDirectory();
+ try
+ {
+ using JsonFileChatHistoryProvider provider = new(directory);
+ ChatClientAgent agent = CreateAgent(provider);
+ AgentSession session = await agent.CreateSessionAsync();
+ ChatMessage request = new(
+ ChatRole.User,
+ [new FunctionCallContent("call-id", "sample-tool")]);
+
+ IEnumerable input = await provider.InvokingAsync(
+ new ChatHistoryProvider.InvokingContext(agent, session, [request]));
+
+ await Assert.ThrowsAsync(
+ () => provider.InvokedAsync(
+ new ChatHistoryProvider.InvokedContext(
+ agent,
+ session,
+ input,
+ [new ChatMessage(ChatRole.Assistant, "not stored")])).AsTask());
+
+ Assert.Empty(Directory.EnumerateFiles(directory));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task CanceledSeedDoesNotCreateOrMutateHistoryAsync()
+ {
+ string directory = CreateStoreDirectory();
+ try
+ {
+ using JsonFileChatHistoryProvider provider = new(directory);
+ using CancellationTokenSource cancellation = new();
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ () => provider.SeedHistoryAsync(
+ "canceled.json",
+ minimumStoreBytes: 1024,
+ messageTextUtf8Bytes: 512,
+ cancellation.Token));
+
+ Assert.Empty(Directory.EnumerateFiles(directory));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ private static ChatClientAgent CreateAgent(JsonFileChatHistoryProvider provider) =>
+ new(
+ new TestChatClient(),
+ new ChatClientAgentOptions
+ {
+ Name = "test-agent",
+ ChatHistoryProvider = provider,
+ });
+
+ private static string CreateStoreDirectory()
+ {
+ string directory = Path.Combine(
+ AppContext.BaseDirectory,
+ ".test-history",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ private sealed class TestChatClient : IChatClient
+ {
+ public void Dispose()
+ {
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) =>
+ serviceType.IsInstanceOfType(this) ? this : null;
+
+ public Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(
+ new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response")));
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.Yield();
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "test response");
+ }
+ }
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/MarkerInputTests.cs b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/MarkerInputTests.cs
new file mode 100644
index 0000000..0b90b8a
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/MarkerInputTests.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using CustomHistoryProvider;
+
+namespace CustomHistoryProviderTests;
+
+public sealed class MarkerInputTests
+{
+ [Fact]
+ public void AcceptsMarkerAtUtf8ByteLimit()
+ {
+ string marker = new('a', MarkerInput.MaximumUtf8Bytes);
+
+ bool accepted = MarkerInput.TryValidate(marker, out string errorMessage);
+
+ Assert.True(accepted);
+ Assert.Empty(errorMessage);
+ }
+
+ [Fact]
+ public void RejectsMarkerOverUtf8ByteLimit()
+ {
+ string marker = new('a', MarkerInput.MaximumUtf8Bytes + 1);
+
+ bool accepted = MarkerInput.TryValidate(marker, out string errorMessage);
+
+ Assert.False(accepted);
+ Assert.Contains("1024 UTF-8 bytes", errorMessage, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RejectsMultibyteMarkerOverUtf8ByteLimit()
+ {
+ string marker = new('\u20ac', (MarkerInput.MaximumUtf8Bytes / 3) + 1);
+
+ bool accepted = MarkerInput.TryValidate(marker, out string errorMessage);
+
+ Assert.False(accepted);
+ Assert.Contains("1026", errorMessage, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void RejectsMissingMarker(string? marker)
+ {
+ bool accepted = MarkerInput.TryValidate(marker, out string errorMessage);
+
+ Assert.False(accepted);
+ Assert.Equal("A marker is required.", errorMessage);
+ }
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/README.md b/dotnet/samples/DurableAgents/ConsoleApps/README.md
index 3aba333..d1008a6 100644
--- a/dotnet/samples/DurableAgents/ConsoleApps/README.md
+++ b/dotnet/samples/DurableAgents/ConsoleApps/README.md
@@ -9,6 +9,12 @@ This directory contains samples for console app hosting of durable agents. These
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including interactive approval prompts.
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
- **[07_ReliableStreaming](07_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages.
+- **[08_FoundryManagedAgent](08_FoundryManagedAgent)**: Draft/experimental source for binding a server-managed, versioned Microsoft Foundry agent to a stable logical service owner and restoring its opaque service continuation across a host restart. Runtime execution remains gated.
+- **[09_CustomHistoryProvider](09_CustomHistoryProvider)**: Draft/experimental JSON-file external history provider with a stable logical key, more than 1 MiB of cumulative history, a bounded model window, and cold-restored external references. Runtime execution remains gated.
+
+Samples 08 and 09 require schema 2 mailbox state, whose writer remains internal and disabled by
+default. They cannot be enabled in mixed-runtime production until Python, the dashboard, pollers, and
+all other readers meet the rollout floor.
## Running the Samples
diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/FoundryAgentRegistrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/FoundryAgentRegistrationTests.cs
new file mode 100644
index 0000000..8327493
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/FoundryAgentRegistrationTests.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ClientModel;
+using System.ClientModel.Primitives;
+using Azure.AI.Extensions.OpenAI;
+using Azure.AI.Projects;
+using Microsoft.Agents.AI.Foundry;
+
+namespace Microsoft.Agents.AI.DurableTask.Tests.Unit;
+
+public sealed class FoundryAgentRegistrationTests
+{
+ private const string FoundryServiceHistoryProviderKey = "foundry-managed-service.v1";
+
+ [Fact]
+ public async Task ServerManagedFoundryAgentRestoresFixedServiceOwnershipAsync()
+ {
+ AIProjectClient projectClient = new(
+ new Uri("https://example.services.ai.azure.com/api/projects/test"),
+ new FakeAuthenticationTokenProvider());
+ FoundryAgent foundryAgent =
+ projectClient.AsAIAgent(new AgentReference("foundry-managed-agent"));
+
+ ChatClientAgent? innerAgent = foundryAgent.GetService();
+ DurableAgentsOptions options = new();
+ AgentSession serviceSession =
+ await foundryAgent.CreateSessionAsync("service-conversation-id");
+ var serializedSession =
+ await foundryAgent.SerializeSessionAsync(serviceSession);
+ AgentSession restoredSession =
+ await foundryAgent.DeserializeSessionAsync(serializedSession);
+
+ options.AddAIAgent(foundryAgent);
+ options.SetHistoryProviderKey(
+ foundryAgent.Name!,
+ FoundryServiceHistoryProviderKey);
+ options.EnableMailboxWrites = true;
+ options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;
+
+ Assert.NotNull(innerAgent);
+ Assert.Same(innerAgent, DurableAgentHistoryOwnershipResolver.FindChatClientAgent(foundryAgent));
+ (DurableAgentHistoryOwnership ownership, ChatClientAgent? restoredInnerAgent) =
+ DurableAgentHistoryOwnershipResolver.Resolve(foundryAgent, restoredSession);
+ ChatClientAgentSession typedSession =
+ Assert.IsType(restoredSession);
+ Assert.Equal(DurableAgentHistoryOwnership.Service, ownership);
+ Assert.Same(innerAgent, restoredInnerAgent);
+ Assert.Equal("service-conversation-id", typedSession.ConversationId);
+ Assert.Equal(
+ FoundryServiceHistoryProviderKey,
+ options.GetHistoryProviderKey(foundryAgent.Name!));
+ Assert.True(options.EnableMailboxWrites);
+ Assert.Equal(DurableAgentHistoryRetentionMode.KeepAll, options.HistoryRetentionMode);
+ Assert.False(options.IsServiceManagedPerServiceCallHistory(foundryAgent.Name!));
+ }
+
+ private sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
+ {
+ public override GetTokenOptions? CreateTokenOptions(
+ IReadOnlyDictionary properties)
+ {
+ return new GetTokenOptions(new Dictionary());
+ }
+
+ public override AuthenticationToken GetToken(
+ GetTokenOptions options,
+ CancellationToken cancellationToken)
+ {
+ return new AuthenticationToken(
+ "test-token",
+ "Bearer",
+ DateTimeOffset.UtcNow.AddHours(1));
+ }
+
+ public override ValueTask GetTokenAsync(
+ GetTokenOptions options,
+ CancellationToken cancellationToken)
+ {
+ return new(this.GetToken(options, cancellationToken));
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/HistorySampleRegistrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/HistorySampleRegistrationTests.cs
new file mode 100644
index 0000000..3b2c98e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/HistorySampleRegistrationTests.cs
@@ -0,0 +1,475 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ClientModel;
+using System.ClientModel.Primitives;
+using System.Text.Json;
+using Azure.AI.Projects;
+using Azure.AI.Projects.Agents;
+using CustomHistoryProvider;
+using Microsoft.Agents.AI.DurableTask.State;
+using Microsoft.Agents.AI.Foundry;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Microsoft.Agents.AI.DurableTask.Tests.Unit;
+
+public sealed class HistorySampleRegistrationTests
+{
+ private const string FoundryAgentName = "foundry-managed-agent";
+ private const string FoundryServiceHistoryProviderKey = "foundry-managed-service.v1";
+ private const string HistoryAgentName = "HistoryAgent";
+
+ [Fact]
+ public async Task ExternalProviderRegistrationUsesMailboxAcrossProxyRestartAsync()
+ {
+ string directory = CreateStoreDirectory();
+ InProcessDurableStateStore stateStore = new();
+
+ try
+ {
+ RecordingChatClient firstClient = new();
+ using JsonFileChatHistoryProvider firstProvider = new(directory);
+ ChatClientAgent firstAgent = CreateHistoryAgent(firstClient, firstProvider);
+ JsonElement serializedDurableSession;
+ string historyId;
+ AgentSessionId sessionId;
+
+ using (IHost firstHost = CreateHost(
+ firstAgent,
+ stateStore,
+ options =>
+ {
+ options.SetHistoryProviderKey(
+ HistoryAgentName,
+ JsonFileChatHistoryProvider.ProviderKey);
+ options.EnableMailboxWrites = true;
+ options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;
+ },
+ services => services.AddSingleton(firstProvider)))
+ {
+ await firstHost.StartAsync();
+ AIAgent proxy = firstHost.Services.GetRequiredKeyedService(HistoryAgentName);
+ AgentSession session = await proxy.CreateSessionAsync();
+
+ AgentResponse response = await proxy.RunAsync("first request", session);
+
+ Assert.Equal("response-1", response.Text);
+ serializedDurableSession = await proxy.SerializeSessionAsync(session);
+ sessionId = session.GetService();
+ DurableAgentState firstState = stateStore.ReadRequired(sessionId);
+ historyId = Assert.Single(firstProvider.GetObservedHistoryIds());
+ AssertMailboxOnlyState(
+ firstState,
+ DurableAgentStateHistoryBinding.HistoryProviderOwner,
+ JsonFileChatHistoryProvider.ProviderKey,
+ completionCount: 1);
+ Assert.True(firstHost.Services.GetRequiredService().EnableMailboxWrites);
+ Assert.Equal(
+ DurableAgentHistoryRetentionMode.KeepAll,
+ firstHost.Services.GetRequiredService().HistoryRetentionMode);
+ await firstHost.StopAsync();
+ }
+
+ RecordingChatClient secondClient = new();
+ using JsonFileChatHistoryProvider secondProvider = new(directory);
+ ChatClientAgent secondAgent = CreateHistoryAgent(secondClient, secondProvider);
+ using IHost secondHost = CreateHost(
+ secondAgent,
+ stateStore,
+ options =>
+ {
+ options.SetHistoryProviderKey(
+ HistoryAgentName,
+ JsonFileChatHistoryProvider.ProviderKey);
+ options.EnableMailboxWrites = true;
+ options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;
+ },
+ services => services.AddSingleton(secondProvider));
+ await secondHost.StartAsync();
+ AIAgent restoredProxy =
+ secondHost.Services.GetRequiredKeyedService(HistoryAgentName);
+ AgentSession restoredSession =
+ await restoredProxy.DeserializeSessionAsync(serializedDurableSession);
+
+ AgentResponse restoredResponse =
+ await restoredProxy.RunAsync("second request", restoredSession);
+
+ Assert.Equal("response-1", restoredResponse.Text);
+ Assert.Equal(historyId, Assert.Single(secondProvider.GetObservedHistoryIds()));
+ Assert.Equal(
+ ["first request", "response-1", "second request"],
+ secondClient.LastMessages.Select(message => message.Text));
+ AssertMailboxOnlyState(
+ stateStore.ReadRequired(sessionId),
+ DurableAgentStateHistoryBinding.HistoryProviderOwner,
+ JsonFileChatHistoryProvider.ProviderKey,
+ completionCount: 2);
+ await secondHost.StopAsync();
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task FoundryAgentRegistrationRestoresServiceContinuationAcrossProxyRestartAsync()
+ {
+ InProcessDurableStateStore stateStore = new();
+ RecordingChatClient firstClient = new()
+ {
+ ResponseConversationId = "service-conversation-id",
+ };
+ FoundryAgent firstAgent = CreateFoundryAgent(firstClient);
+ AgentSession initialInnerSession = await firstAgent.CreateSessionAsync();
+ Assert.Equal(
+ DurableAgentHistoryOwnership.Entity,
+ DurableAgentHistoryOwnershipResolver.Resolve(firstAgent, initialInnerSession).Ownership);
+
+ JsonElement serializedDurableSession;
+ AgentSessionId sessionId;
+ using (IHost firstHost = CreateHost(
+ firstAgent,
+ stateStore,
+ ConfigureFoundryRegistration))
+ {
+ await firstHost.StartAsync();
+ AIAgent proxy = firstHost.Services.GetRequiredKeyedService(FoundryAgentName);
+ AgentSession session = await proxy.CreateSessionAsync();
+
+ AgentResponse response = await proxy.RunAsync("first request", session);
+
+ Assert.Equal("response-1", response.Text);
+ Assert.Equal(1, firstClient.InvocationCount);
+ Assert.Null(firstClient.LastConversationId);
+ serializedDurableSession = await proxy.SerializeSessionAsync(session);
+ sessionId = session.GetService();
+ DurableAgentState firstState = stateStore.ReadRequired(sessionId);
+ AssertMailboxOnlyState(
+ firstState,
+ DurableAgentStateHistoryBinding.ModelServiceOwner,
+ FoundryServiceHistoryProviderKey,
+ completionCount: 1);
+ Assert.Equal(
+ "service-conversation-id",
+ firstState.Data.Session?.GetProperty("conversationId").GetString());
+ await firstHost.StopAsync();
+ }
+
+ RecordingChatClient secondClient = new();
+ FoundryAgent secondAgent = CreateFoundryAgent(secondClient);
+ using IHost secondHost = CreateHost(
+ secondAgent,
+ stateStore,
+ ConfigureFoundryRegistration);
+ await secondHost.StartAsync();
+ AIAgent restoredProxy =
+ secondHost.Services.GetRequiredKeyedService(FoundryAgentName);
+ AgentSession restoredSession =
+ await restoredProxy.DeserializeSessionAsync(serializedDurableSession);
+
+ AgentResponse restoredResponse =
+ await restoredProxy.RunAsync("second request", restoredSession);
+
+ Assert.Equal("response-1", restoredResponse.Text);
+ Assert.Equal(1, secondClient.InvocationCount);
+ Assert.Equal("service-conversation-id", secondClient.LastConversationId);
+ Assert.Equal(["second request"], secondClient.LastMessages.Select(message => message.Text));
+ DurableAgentState restoredState = stateStore.ReadRequired(sessionId);
+ AssertMailboxOnlyState(
+ restoredState,
+ DurableAgentStateHistoryBinding.ModelServiceOwner,
+ FoundryServiceHistoryProviderKey,
+ completionCount: 2);
+ Assert.Equal(
+ "service-conversation-id",
+ restoredState.Data.Session?.GetProperty("conversationId").GetString());
+ await secondHost.StopAsync();
+ }
+
+ private static FoundryAgent CreateFoundryAgent(RecordingChatClient client)
+ {
+ AIProjectClient projectClient = new(
+ new Uri("https://example.services.ai.azure.com/api/projects/test"),
+ new FakeAuthenticationTokenProvider());
+ ProjectsAgentVersion agentVersion =
+ ProjectsAgentsModelFactory.ProjectsAgentVersion(
+ id: $"{FoundryAgentName}:1",
+ name: FoundryAgentName,
+ version: "1");
+ return projectClient.AsAIAgent(
+ agentVersion,
+ clientFactory: _ => client);
+ }
+
+ [Fact]
+ public async Task ExternalOwnershipWithoutMailboxActivationFailsClearlyThroughProxyAsync()
+ {
+ string directory = CreateStoreDirectory();
+ InProcessDurableStateStore stateStore = new();
+
+ try
+ {
+ RecordingChatClient client = new();
+ using JsonFileChatHistoryProvider provider = new(directory);
+ ChatClientAgent agent = CreateHistoryAgent(client, provider);
+ using IHost host = CreateHost(
+ agent,
+ stateStore,
+ options =>
+ {
+ options.SetHistoryProviderKey(
+ HistoryAgentName,
+ JsonFileChatHistoryProvider.ProviderKey);
+ options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;
+ },
+ services => services.AddSingleton(provider));
+ await host.StartAsync();
+ AIAgent proxy = host.Services.GetRequiredKeyedService(HistoryAgentName);
+ AgentSession session = await proxy.CreateSessionAsync();
+
+ InvalidOperationException exception =
+ await Assert.ThrowsAsync(
+ () => proxy.RunAsync("request", session));
+
+ Assert.Contains("schema 2 mailbox writes", exception.Message, StringComparison.Ordinal);
+ Assert.Contains(
+ "Enable mailbox writes",
+ exception.Message,
+ StringComparison.Ordinal);
+ Assert.Equal(0, client.InvocationCount);
+ Assert.Empty(provider.GetObservedHistoryIds());
+ Assert.Empty(Directory.EnumerateFiles(directory));
+ Assert.False(stateStore.TryRead(session.GetService(), out _));
+ await host.StopAsync();
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ private static IHost CreateHost(
+ AIAgent agent,
+ InProcessDurableStateStore stateStore,
+ Action configure,
+ Action? configureServices = null)
+ {
+ return Host.CreateDefaultBuilder()
+ .ConfigureServices(services =>
+ {
+ configureServices?.Invoke(services);
+ services.ConfigureDurableAgents(options =>
+ {
+ options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1));
+ configure(options);
+ });
+ services.AddSingleton(
+ _ => new Mock("test").Object);
+ services.AddSingleton(
+ serviceProvider => new InProcessDurableAgentClient(
+ serviceProvider,
+ stateStore));
+ })
+ .Build();
+ }
+
+ private static void ConfigureFoundryRegistration(DurableAgentsOptions options)
+ {
+ options.SetHistoryProviderKey(
+ FoundryAgentName,
+ FoundryServiceHistoryProviderKey);
+ options.EnableMailboxWrites = true;
+ options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;
+ }
+
+ private static ChatClientAgent CreateHistoryAgent(
+ RecordingChatClient client,
+ JsonFileChatHistoryProvider provider) =>
+ new(
+ client,
+ new ChatClientAgentOptions
+ {
+ Name = HistoryAgentName,
+ ChatHistoryProvider = provider,
+ });
+
+ private static void AssertMailboxOnlyState(
+ DurableAgentState state,
+ string ownerKind,
+ string providerKey,
+ int completionCount)
+ {
+ Assert.Equal(DurableAgentState.RevisedSchemaVersion, state.SchemaVersion);
+ Assert.Empty(state.Data.ConversationHistory);
+ DurableAgentStateHistoryBinding? historyBinding =
+ DurableAgentHistoryBinding.Parse(state.Data.HistoryBinding);
+ Assert.Equal(ownerKind, historyBinding?.OwnerKind);
+ Assert.Equal(providerKey, historyBinding?.ProviderKey);
+ Assert.Equal(completionCount, state.Data.TerminalResults?.Count);
+ Assert.Equal(completionCount, state.Data.CompletionReceipts?.Count);
+ }
+
+ private static string CreateStoreDirectory()
+ {
+ string directory = Path.Combine(
+ AppContext.BaseDirectory,
+ ".registration-history",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ private sealed class InProcessDurableAgentClient(
+ IServiceProvider services,
+ InProcessDurableStateStore stateStore) : IDurableAgentClient
+ {
+ public async Task RunAgentAsync(
+ AgentSessionId sessionId,
+ RunRequest request,
+ CancellationToken cancellationToken)
+ {
+ DurableAgentState? hydratedState =
+ stateStore.TryRead(sessionId, out DurableAgentState? existingState)
+ ? existingState
+ : null;
+
+ Mock context = new();
+ context.SetupGet(value => value.Id).Returns(sessionId);
+
+ Mock entityState = new();
+ entityState.Setup(value => value.GetState(typeof(DurableAgentState)))
+ .Returns(hydratedState);
+ entityState.Setup(value => value.SetState(It.IsAny