From 28f9953bd78f71d119d2c1de78b03f622308d2c3 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Tue, 8 Sep 2026 21:07:01 +0300 Subject: [PATCH] Add durable chat history ownership and session persistence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentEntity.cs | 373 ++- ...bleAgentCompactionNotSupportedException.cs | 39 + .../DurableAgentHistoryBinding.cs | 509 ++++ ...bleAgentHistoryBindingMismatchException.cs | 32 + .../DurableAgentHistoryOwnership.cs | 192 ++ ...ntHistoryOwnershipNotSupportedException.cs | 40 + .../DurableAgentHistoryReplayMode.cs | 26 + .../DurableAgentSessionState.cs | 41 + .../DurableAgentsOptions.cs | 119 + .../DurableChatHistoryProvider.cs | 100 + .../EntityAgentWrapper.cs | 40 +- .../Microsoft.Agents.AI.DurableTask/Logs.cs | 20 +- .../Microsoft.Agents.AI.DurableTask/README.md | 47 + .../State/DurableAgentStateMessage.cs | 49 +- .../State/DurableAgentStateReplay.cs | 37 + .../State/DurableAgentStateRequest.cs | 16 +- .../State/DurableAgentStateResponse.cs | 20 +- .../AgentEntityDeliveryTests.cs | 133 +- .../AgentEntityHistoryTests.cs | 2361 +++++++++++++++++ .../DurableAgentHistoryOwnershipTests.cs | 502 ++++ .../DurableAgentSessionStateTests.cs | 211 ++ .../DurableChatHistoryProviderTests.cs | 478 ++++ .../EntityAgentWrapperTests.cs | 211 ++ 23 files changed, 5545 insertions(+), 51 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentCompactionNotSupportedException.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBinding.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBindingMismatchException.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnership.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnershipNotSupportedException.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryReplayMode.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentSessionState.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableChatHistoryProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateReplay.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityHistoryTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentHistoryOwnershipTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentSessionStateTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableChatHistoryProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/EntityAgentWrapperTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs index 85ab765..7b1c499 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs @@ -13,14 +13,20 @@ namespace Microsoft.Agents.AI.DurableTask; internal class AgentEntity(IServiceProvider services, CancellationToken cancellationToken = default) : TaskEntity, ITaskEntity { + private const string HistoryProviderConflictMessage = + "Only ConversationId or ChatHistoryProvider may be used, but not both. " + + "The service returned a conversation id indicating server-side chat history management, " + + "but the agent has a ChatHistoryProvider configured."; + private const string MissingServiceConversationIdMessage = + "Service did not return a valid conversation id when using an AgentSession with service managed chat history."; private static readonly TimeSpan s_minimumResultExpirationSignalDelay = TimeSpan.FromMinutes(1); private readonly IServiceProvider _services = services; private readonly DurableTaskClient _client = services.GetRequiredService(); private readonly ILoggerFactory _loggerFactory = services.GetRequiredService(); private readonly IAgentResponseHandler? _messageHandler = services.GetService(); private readonly DurableAgentsOptions _options = services.GetRequiredService(); - // Entity operations execute once rather than replaying like orchestrations, and - // TaskEntityContext does not expose a deterministic clock. + // Entity operations rehydrate and execute once rather than replaying like orchestrations, and + // TaskEntityContext has no deterministic clock. Use wall-clock UTC through an injectable source. private readonly TimeProvider _timeProvider = services.GetService() ?? TimeProvider.System; private readonly CancellationToken _cancellationToken = cancellationToken != default ? cancellationToken @@ -94,6 +100,7 @@ public async Task Run(RunRequest request) ArgumentNullException.ThrowIfNull(request); AgentSessionId sessionId = this.Context.Id; + // Logger category is Microsoft.DurableTask.Agents.{registeredAgentName}.{sessionId} ILogger logger = this.GetLogger(sessionId.Name, sessionId.Key); string correlationId = request.CorrelationId; @@ -178,12 +185,41 @@ public async Task Run(RunRequest request) _ = AgentEntityResultExpirySchedule.Read(workingState, this.Context.Id.ToString()); } - workingState.Data.ConversationHistory.Add( - workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion - ? DurableAgentStateRequest.FromRunRequestV2(request, logger) - : DurableAgentStateRequest.FromRunRequest(request, logger)); + bool isLegacyState = + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion; + DurableAgentStateHistoryBinding? persistedHistoryBinding = + DurableAgentHistoryBinding.Parse(this.State.Data.HistoryBinding); + DurableAgentHistoryBinding.ValidateMarkedProfile( + this.State.Data.HistoryBinding, + persistedHistoryBinding); + DurableAgentStateHistoryBinding? existingHistoryBinding = + DurableAgentHistoryBinding.IsSealedByCSharp(persistedHistoryBinding) + ? persistedHistoryBinding + : null; + string? configuredHistoryProviderKey = + this._options.GetHistoryProviderKey(sessionId.Name) ?? + persistedHistoryBinding?.ProviderKey; + DurableAgentHistoryBinding.ValidateContinuationPresence( + existingHistoryBinding, + this.State.Data.Session); + DurableAgentHistoryBinding.ValidateConfiguredKey( + existingHistoryBinding, + configuredHistoryProviderKey); + if (workingState.SchemaVersion != DurableAgentState.RevisedSchemaVersion) + { + workingState.Data.ConversationHistory.Add( + DurableAgentStateRequest.FromRunRequest(request, logger)); + } + AIAgent agent = this.GetAgent(sessionId); - EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services); + bool serviceManagedPerServiceCallHistory = + this._options.IsServiceManagedPerServiceCallHistory(sessionId.Name); + ValidatedDurableAgentHistoryConfiguration validatedHistoryConfiguration = + DurableAgentHistoryOwnershipResolver.ValidateRunConfiguration( + agent, + serviceManagedPerServiceCallHistory); + DurableAgentHistoryReplayMode historyReplayMode = + this._options.GetHistoryReplayMode(sessionId.Name); foreach (ChatMessage msg in request.Messages) { @@ -201,13 +237,98 @@ public async Task Run(RunRequest request) try { + AgentSession session = await DurableAgentSessionState.RestoreAsync( + agent, + workingState.Data.Session, + this._cancellationToken).ConfigureAwait(false); + (DurableAgentHistoryOwnership ownership, ChatClientAgent? chatClientAgent) = + DurableAgentHistoryOwnershipResolver.Resolve( + session, + validatedHistoryConfiguration); + DurableAgentHistoryOwnership effectiveOwnership = + DurableAgentHistoryOwnershipResolver.GetEffectiveOwnership( + ownership, + historyReplayMode); + DurableAgentHistoryBinding.ValidatePreExecutionContinuationContract( + effectiveOwnership, + session, + chatClientAgent, + validatedHistoryConfiguration.RequiresPerServiceCallPersistence); + if (effectiveOwnership != DurableAgentHistoryOwnership.Entity && + this.State.Data.HistoryBinding.ValueKind != JsonValueKind.Undefined && + persistedHistoryBinding is null) + { + throw new DurableAgentHistoryBindingMismatchException( + "The durable session contains an opaque shared historyBinding that the C# runtime " + + $"cannot use to prove {effectiveOwnership} ownership. Preserve that state with its " + + "originating runtime or start a new C# durable session with an explicit logical provider key."); + } + + if (effectiveOwnership != DurableAgentHistoryOwnership.Entity && + workingState.SchemaVersion != DurableAgentState.RevisedSchemaVersion) + { + throw new InvalidOperationException( + "External, service, and opaque agent-session history require schema 2 mailbox writes. " + + "Enable mailbox writes and authorize any legacy migration before continuing this durable session."); + } + + DurableAgentStateHistoryBinding expectedHistoryBinding = + DurableAgentHistoryBinding.Create( + effectiveOwnership, + configuredHistoryProviderKey); + if (existingHistoryBinding is null) + { + DurableAgentHistoryBinding.ValidateLegacyAdoption( + this.State, + effectiveOwnership, + session, + chatClientAgent, + validatedHistoryConfiguration.RequiresPerServiceCallPersistence); + } + DurableAgentHistoryBinding.ValidateExisting( + existingHistoryBinding, + expectedHistoryBinding); + if (existingHistoryBinding is not null) + { + DurableAgentHistoryBinding.ValidateBoundContinuation( + effectiveOwnership, + session, + chatClientAgent); + } + bool entityOwnedHistory = + effectiveOwnership == DurableAgentHistoryOwnership.Entity; + + // The provider is bound per invocation because it needs this operation's working state and + // correlation ID. A registration-time provider cannot safely bind either value. + DurableChatHistoryProvider? durableHistoryProvider = + entityOwnedHistory && + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? new( + workingState.Data.ConversationHistory, + request, + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion, + logger) + : null; + EntityAgentWrapper agentWrapper = new( + agent, + this.Context, + request, + this._services, + durableHistoryProvider); + + IEnumerable inputMessages = BuildAgentInputMessages( + workingState, + request, + effectiveOwnership, + chatClientAgent is not null && + (durableHistoryProvider is not null || !entityOwnedHistory), + historyReplayMode, + workingState.SchemaVersion != DurableAgentState.RevisedSchemaVersion); + // Start the agent response stream IAsyncEnumerable responseStream = agentWrapper.RunStreamingAsync( - workingState.Data.ConversationHistory.SelectMany(e => e.Messages).Select( - message => workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion - ? message.ToChatMessageV2() - : message.ToChatMessage()), - await agentWrapper.CreateSessionAsync(this._cancellationToken).ConfigureAwait(false), + inputMessages, + session, options: null, this._cancellationToken); @@ -266,12 +387,90 @@ async IAsyncEnumerable StreamResultsAsync() response.ContinuationToken = continuationToken; #pragma warning restore MEAI001 - // Persist the agent response to the entity state for client polling - DurableAgentStateResponse storedResponse = - workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion - ? DurableAgentStateResponse.FromResponseV2(correlationId, response, logger) - : DurableAgentStateResponse.FromResponse(correlationId, response, logger); - workingState.Data.ConversationHistory.Add(storedResponse); + (DurableAgentHistoryOwnership finalOwnership, _) = + DurableAgentHistoryOwnershipResolver.Resolve( + session, + validatedHistoryConfiguration); + finalOwnership = DurableAgentHistoryOwnershipResolver.GetEffectiveOwnership( + finalOwnership, + historyReplayMode); + bool remoteServiceTransition = + finalOwnership != effectiveOwnership && + finalOwnership == DurableAgentHistoryOwnership.Service; + if (finalOwnership != DurableAgentHistoryOwnership.Entity && + this.State.Data.HistoryBinding.ValueKind != JsonValueKind.Undefined && + persistedHistoryBinding is null) + { + throw new DurableAgentHistoryBindingMismatchException( + "The completed call resolved non-entity history ownership, but the durable session " + + "contains an opaque shared historyBinding that the C# runtime cannot seal or resume. " + + "Durable state was not committed. The remote service may already have observed the call; " + + "preserve the state with its originating runtime or start a new C# durable session."); + } + + DurableAgentStateHistoryBinding finalHistoryBinding = + DurableAgentHistoryBinding.Create( + finalOwnership, + configuredHistoryProviderKey, + remoteServiceTransition); + if (existingHistoryBinding is null) + { + DurableAgentHistoryBinding.ValidateLegacyTransition( + this.State, + effectiveOwnership, + finalOwnership, + remoteServiceTransition); + } + + DurableAgentHistoryBinding.ValidateExisting( + existingHistoryBinding, + finalHistoryBinding, + remoteTransitionDetectedAfterExecution: remoteServiceTransition); + DurableAgentHistoryBinding.ValidateBoundContinuation( + finalOwnership, + session, + chatClientAgent, + remoteServiceTransition); + + FinalizeConversationEntries( + workingState, + request, + response, + finalOwnership, + durableHistoryProvider, + logger); + + workingState.Data.Session = await SerializeSessionWithoutDuplicateHistoryAsync( + agent, + session, + chatClientAgent, + finalOwnership, + this._cancellationToken).ConfigureAwait(false); + if (workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion) + { + if (existingHistoryBinding is not null || + persistedHistoryBinding is not null || + this.State.Data.HistoryBinding.ValueKind == JsonValueKind.Undefined) + { + DurableAgentStateHistoryBinding bindingToSeal = + existingHistoryBinding ?? + DurableAgentHistoryBinding.MergeProvisionalMetadata( + finalHistoryBinding, + persistedHistoryBinding); + workingState = DurableAgentHistoryBinding.Seal( + workingState, + bindingToSeal); + } + + workingState.MailboxWritesAuthorized = true; + } + + DurableAgentStateResponse? storedResponse = + finalOwnership == DurableAgentHistoryOwnership.Entity + ? workingState.Data.ConversationHistory + .OfType() + .LastOrDefault(entry => entry.CorrelationId == correlationId) + : null; if (workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion) { DateTimeOffset completedAt = this._timeProvider.GetUtcNow(); @@ -285,7 +484,7 @@ async IAsyncEnumerable StreamResultsAsync() DurableAgentJsonUtilities.CaptureRetainedResult( response, workingState.Data.TerminalResults![correlationId].Response!); } - else + else if (storedResponse is not null) { DurableAgentJsonUtilities.CaptureRetainedLegacyResult(response, storedResponse); } @@ -309,6 +508,19 @@ async IAsyncEnumerable StreamResultsAsync() return response; } + catch (InvalidOperationException exception) when ( + IsPostResponseServiceHistoryFailure( + exception, + validatedHistoryConfiguration.ChatClientAgent)) + { + DurableAgentHistoryBindingMismatchException bindingException = new( + "Agent Framework rejected the completed call while updating service history ownership. " + + exception.Message + + " The remote service may already have observed the rejected call, but durable state was not committed.", + exception); + logger.LogDurableAgentExecutionFailed(bindingException, sessionId); + throw bindingException; + } catch (Exception exception) { logger.LogDurableAgentExecutionFailed(exception, sessionId); @@ -393,7 +605,7 @@ public void CheckAndDeleteIfExpired(AgentEntityDeletionCheck? scheduledCheck = n logger.LogTTLDeletionCheck(sessionId, expirationTime, currentTime); // A delayed signal can outlive a deleted entity. TaskEntity initializes missing state - // before dispatch, so remove that otherwise-empty placeholder instead of recreating it. + // before dispatch, so delete that otherwise-empty placeholder instead of recreating it. if (!expirationTime.HasValue && IsEmptyInitializedState(this.State)) { this.State = null!; @@ -411,6 +623,7 @@ public void CheckAndDeleteIfExpired(AgentEntityDeletionCheck? scheduledCheck = n !this._options.GetTimeToLive( sessionId.Name, this.State.SchemaVersion == DurableAgentState.RevisedSchemaVersion).HasValue) { + // Configuration can change while a durable delayed signal is outstanding. if (expirationTime.HasValue) { logger.LogTTLExpirationTimeCleared(sessionId); @@ -435,7 +648,8 @@ public void CheckAndDeleteIfExpired(AgentEntityDeletionCheck? scheduledCheck = n return; } - // A shorter TTL creates an earlier signal. Its older, later counterpart is stale. + // Later interactions normally extend expiration and let the earlier signal move the chain + // forward. A shorter TTL schedules an earlier signal; its older, later counterpart is stale. if (scheduledCheck is null || scheduledCheck.ExpectedExpirationTimeUtc <= expirationTime.Value) { @@ -459,6 +673,26 @@ state.ExtensionData is null && state.UnknownProperties is null; } + private static bool IsPostResponseServiceHistoryFailure( + InvalidOperationException exception, + ChatClientAgent? chatClientAgent) + { + if (chatClientAgent is null) + { + return false; + } + + return string.Equals( + exception.Message, + MissingServiceConversationIdMessage, + StringComparison.Ordinal) || + (chatClientAgent.ChatHistoryProvider is not null && + string.Equals( + exception.Message, + HistoryProviderConflictMessage, + StringComparison.Ordinal)); + } + private void ScheduleDeletionCheck( AgentSessionId sessionId, ILogger logger, @@ -482,6 +716,96 @@ private void ScheduleDeletionCheck( options: new SignalEntityOptions { SignalTime = scheduledTime }); } + private static IEnumerable BuildAgentInputMessages( + DurableAgentState workingState, + RunRequest request, + DurableAgentHistoryOwnership ownership, + bool contextPipelineSuppliesHistory, + DurableAgentHistoryReplayMode historyReplayMode, + bool isLegacyState) + { + if (contextPipelineSuppliesHistory || + ownership == DurableAgentHistoryOwnership.AgentSession || + historyReplayMode == DurableAgentHistoryReplayMode.CurrentRequestOnly) + { + // A MAF history/context pipeline or a server-owned opaque session supplies prior context. + // Passing stored history here as well would duplicate messages. + return request.Messages; + } + + if (isLegacyState) + { + return workingState.Data.ConversationHistory + .SelectMany(entry => entry.Messages) + .Select(message => message.ToChatMessage()); + } + + // Generic AIAgents have no discoverable context pipeline. In the backward-compatible preload + // mode, the entity manually replays prior durable history before the current request. + return DurableAgentStateReplay.GetMessages( + workingState.Data.ConversationHistory, + request.CorrelationId) + .Concat(request.Messages); + } + + private static void FinalizeConversationEntries( + DurableAgentState workingState, + RunRequest request, + AgentResponse response, + DurableAgentHistoryOwnership ownership, + DurableChatHistoryProvider? durableHistoryProvider, + ILogger logger) + { + if (ownership != DurableAgentHistoryOwnership.Entity) + { + // Delivery is recorded in the schema 2 mailbox. Provider-, service-, and opaque + // agent-session owners keep their transcript outside conversationHistory. + return; + } + + if (durableHistoryProvider?.HasStagedTurn is true) + { + // Provider callbacks already staged the entity-owned request and response. Replace only + // the staged response so aggregate usage and response metadata are retained once. + durableHistoryProvider.CompleteStagedResponse(response); + return; + } + + if (workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion) + { + workingState.Data.ConversationHistory.Add( + DurableAgentStateRequest.FromRunRequestV2(request, logger)); + } + + workingState.Data.ConversationHistory.Add( + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateResponse.FromResponseV2(request.CorrelationId, response, logger) + : DurableAgentStateResponse.FromResponse(request.CorrelationId, response, logger)); + } + + private static ValueTask SerializeSessionWithoutDuplicateHistoryAsync( + AIAgent agent, + AgentSession session, + ChatClientAgent? chatClientAgent, + DurableAgentHistoryOwnership ownership, + CancellationToken cancellationToken) + { + // InMemoryChatHistoryProvider state can contain a full transcript already retained by the + // entity. Exclude only that provider's declared keys; custom, compaction, and opaque + // server-session state remains authoritative and is preserved. + IEnumerable excludedStateKeys = + chatClientAgent?.ChatHistoryProvider is InMemoryChatHistoryProvider inMemoryHistoryProvider && + ownership is DurableAgentHistoryOwnership.Entity or DurableAgentHistoryOwnership.Service + ? inMemoryHistoryProvider.StateKeys + : []; + + return DurableAgentSessionState.SerializeAsync( + agent, + session, + excludedStateKeys, + cancellationToken); + } + private DateTime? UpdateExpiration( DurableAgentState workingState, AgentSessionId sessionId, @@ -506,8 +830,8 @@ private void ScheduleDeletionCheck( workingState.Data.ExpirationTimeUtc = newExpirationTime; logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime); - // The first turn starts one delayed-check chain. An extension is picked up by the - // existing signal; only a shortened expiration needs a new earlier signal. + // The first turn starts one delayed-check chain. Extended expirations are picked up by the + // earlier check; only a shortened expiration needs a new earlier signal. return !previousExpirationTime.HasValue || newExpirationTime < previousExpirationTime.Value ? newExpirationTime @@ -577,7 +901,8 @@ private void CommitWorkingState( ValidateForCommit(workingState); if (deletionCheckExpiration.HasValue) { - // this.State still points at the hydrated state until the final assignment. + // Pass the working-copy value explicitly: this.State still refers to the original state + // until the operation commits. this.ScheduleDeletionCheck(sessionId, logger, deletionCheckExpiration.Value); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentCompactionNotSupportedException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentCompactionNotSupportedException.cs new file mode 100644 index 0000000..1b68dff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentCompactionNotSupportedException.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// The exception thrown when a durable agent is combined with unsupported stateful compaction. +/// +public sealed class DurableAgentCompactionNotSupportedException : NotSupportedException +{ + private const string DefaultMessage = + "Durable .NET agents do not support directly discoverable stateful chat-history compaction. " + + "The current Agent Framework compaction state contains full message copies. Persisting it duplicates " + + "transcript content for entity, external-provider, and service-owned conversations, while discarding it " + + "loses compaction semantics. Disable stateful compaction for durable execution."; + + /// + /// Initializes a new instance of the class. + /// + public DurableAgentCompactionNotSupportedException() + : base(DefaultMessage) + { + } + + /// + /// Initializes a new instance with a specified error message. + /// + public DurableAgentCompactionNotSupportedException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance with a specified error message and inner exception. + /// + public DurableAgentCompactionNotSupportedException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBinding.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBinding.cs new file mode 100644 index 0000000..1d1fa60 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBinding.cs @@ -0,0 +1,509 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.State; + +namespace Microsoft.Agents.AI.DurableTask; + +internal sealed class DurableAgentStateHistoryBinding +{ + public const int CurrentVersion = 1; + public const string DurableStateOwner = "durableState"; + public const string HistoryProviderOwner = "historyProvider"; + public const string ModelServiceOwner = "modelService"; + + public int Version { get; init; } = CurrentVersion; + + public required string OwnerKind { get; init; } + + public required string ProviderKey { get; init; } + + public IDictionary? UnknownProperties { get; set; } +} + +/// +/// Creates and validates the fixed logical history binding for a durable session. +/// +internal static class DurableAgentHistoryBinding +{ + internal const string DurableStateProviderKey = "durable-state.v1"; + internal const string FrameworkLocalHistoryConversationId = "_agent_local_chat_history"; + private const string CSharpFixedOwnerProperty = "csharpFixedOwner"; + + public static DurableAgentStateHistoryBinding Create( + DurableAgentHistoryOwnership ownership, + string? configuredProviderKey, + bool remoteTransitionDetectedAfterExecution = false) + { + DurableAgentStateHistoryBinding binding = ownership switch + { + DurableAgentHistoryOwnership.Entity => new() + { + OwnerKind = DurableAgentStateHistoryBinding.DurableStateOwner, + ProviderKey = DurableStateProviderKey, + }, + DurableAgentHistoryOwnership.ExternalProvider or + DurableAgentHistoryOwnership.AgentSession => new() + { + OwnerKind = DurableAgentStateHistoryBinding.HistoryProviderOwner, + ProviderKey = RequireProviderKey( + ownership, + configuredProviderKey, + remoteTransitionDetectedAfterExecution), + }, + DurableAgentHistoryOwnership.Service => new() + { + OwnerKind = DurableAgentStateHistoryBinding.ModelServiceOwner, + ProviderKey = RequireProviderKey( + ownership, + configuredProviderKey, + remoteTransitionDetectedAfterExecution), + }, + _ => throw new InvalidOperationException( + $"History ownership '{ownership}' is not a sealable durable owner."), + }; + binding.UnknownProperties = new Dictionary + { + [CSharpFixedOwnerProperty] = + System.Text.Json.JsonDocument.Parse("true").RootElement.Clone(), + }; + return binding; + } + + public static bool IsSealedByCSharp(DurableAgentStateHistoryBinding? binding) + { + return binding?.UnknownProperties?.TryGetValue( + CSharpFixedOwnerProperty, + out System.Text.Json.JsonElement value) is true && + value.ValueKind is System.Text.Json.JsonValueKind.True; + } + + public static DurableAgentStateHistoryBinding? Parse( + System.Text.Json.JsonElement binding) + { + if (binding.ValueKind == System.Text.Json.JsonValueKind.Undefined) + { + return null; + } + + if (binding.ValueKind != System.Text.Json.JsonValueKind.Object || + !binding.TryGetProperty("version", out System.Text.Json.JsonElement version) || + !version.TryGetInt32(out int versionValue) || + !binding.TryGetProperty("ownerKind", out System.Text.Json.JsonElement ownerKind) || + ownerKind.ValueKind != System.Text.Json.JsonValueKind.String || + !binding.TryGetProperty("providerKey", out System.Text.Json.JsonElement providerKey) || + providerKey.ValueKind != System.Text.Json.JsonValueKind.String) + { + return null; + } + + string ownerKindValue = ownerKind.GetString()!; + string providerKeyValue = providerKey.GetString()!; + if (versionValue != DurableAgentStateHistoryBinding.CurrentVersion || + ownerKindValue is not ( + DurableAgentStateHistoryBinding.DurableStateOwner or + DurableAgentStateHistoryBinding.HistoryProviderOwner or + DurableAgentStateHistoryBinding.ModelServiceOwner) || + string.IsNullOrWhiteSpace(providerKeyValue)) + { + return null; + } + + Dictionary unknown = []; + foreach (System.Text.Json.JsonProperty property in binding.EnumerateObject()) + { + if (property.Name is not "version" and not "ownerKind" and not "providerKey") + { + unknown[property.Name] = property.Value.Clone(); + } + } + + return new DurableAgentStateHistoryBinding + { + Version = versionValue, + OwnerKind = ownerKindValue, + ProviderKey = providerKeyValue, + UnknownProperties = unknown.Count == 0 ? null : unknown, + }; + } + + public static void ValidateMarkedProfile( + System.Text.Json.JsonElement binding, + DurableAgentStateHistoryBinding? parsed) + { + if (binding.ValueKind != System.Text.Json.JsonValueKind.Object || + !binding.TryGetProperty( + CSharpFixedOwnerProperty, + out System.Text.Json.JsonElement marker) || + marker.ValueKind != System.Text.Json.JsonValueKind.True) + { + return; + } + + if (parsed is null) + { + throw new DurableAgentHistoryBindingMismatchException( + "The durable session contains a C# fixed-owner history profile that is malformed or " + + "uses an unsupported version. Restore a supported profile or start a new durable session."); + } + + if (parsed.OwnerKind == DurableAgentStateHistoryBinding.DurableStateOwner && + !string.Equals( + parsed.ProviderKey, + DurableStateProviderKey, + StringComparison.Ordinal)) + { + throw new DurableAgentHistoryBindingMismatchException( + $"The C# entity-owned history profile must use provider key '{DurableStateProviderKey}', " + + $"not '{parsed.ProviderKey}'. Restore a supported profile or start a new durable session."); + } + } + + public static void ValidateExisting( + DurableAgentStateHistoryBinding? existing, + DurableAgentStateHistoryBinding expected, + bool remoteTransitionDetectedAfterExecution = false) + { + if (existing is null) + { + return; + } + + if (existing.Version == expected.Version && + string.Equals(existing.OwnerKind, expected.OwnerKind, StringComparison.Ordinal) && + string.Equals(existing.ProviderKey, expected.ProviderKey, StringComparison.Ordinal)) + { + return; + } + + throw new DurableAgentHistoryBindingMismatchException( + $"Durable session history is fixed to '{existing.OwnerKind}/{existing.ProviderKey}' " + + $"but the configured runtime resolved '{expected.OwnerKind}/{expected.ProviderKey}'. " + + "Restore the original logical provider configuration or start a new durable session." + + GetRemoteTransitionSuffix(remoteTransitionDetectedAfterExecution)); + } + + public static void ValidateLegacyAdoption( + DurableAgentState legacyState, + DurableAgentHistoryOwnership ownership, + AgentSession restoredSession, + ChatClientAgent? chatClientAgent, + bool requiresPerServiceCallPersistence) + { + if (!HasPriorContinuity(legacyState) && + !HasInMemoryProviderMessages(restoredSession, chatClientAgent)) + { + return; + } + + if (ownership == DurableAgentHistoryOwnership.Entity) + { + if (!HasInMemoryProviderMessages(restoredSession, chatClientAgent)) + { + return; + } + + throw new DurableAgentHistoryBindingMismatchException( + "The unsealed durable session contains in-memory provider history that is not proven " + + "equivalent to conversationHistory. Automatic transcript merging is not supported; " + + "restore an entity-owned durable transcript or start a new durable session."); + } + + bool continuityProven = ownership switch + { + DurableAgentHistoryOwnership.Service => + !requiresPerServiceCallPersistence && + restoredSession is ChatClientAgentSession serviceSession && + IsRealServiceConversationId(serviceSession.ConversationId), + DurableAgentHistoryOwnership.ExternalProvider => + HasDeclaredProviderContinuation(restoredSession, chatClientAgent?.ChatHistoryProvider), + _ => false, + }; + if (continuityProven) + { + return; + } + + throw new DurableAgentHistoryBindingMismatchException( + "Legacy durable state has conversation evidence but no owner-specific continuation that proves " + + $"the configured {ownership} owner is the same logical history store. Restore the prior " + + "configuration or start a new durable session; automatic history migration is not supported."); + } + + public static void ValidateLegacyTransition( + DurableAgentState legacyState, + DurableAgentHistoryOwnership initialOwnership, + DurableAgentHistoryOwnership finalOwnership, + bool remoteTransitionDetectedAfterExecution) + { + if (initialOwnership == finalOwnership || + !HasPriorContinuity(legacyState)) + { + return; + } + + throw new DurableAgentHistoryBindingMismatchException( + $"Legacy durable state was initially resolved as '{initialOwnership}' but the completed call " + + $"resolved '{finalOwnership}'. Automatic history-owner switching or migration is not supported." + + GetRemoteTransitionSuffix(remoteTransitionDetectedAfterExecution)); + } + + public static void ValidateContinuationPresence( + DurableAgentStateHistoryBinding? existing, + System.Text.Json.JsonElement? serializedSession) + { + if (existing is null || + existing.OwnerKind == DurableAgentStateHistoryBinding.DurableStateOwner || + serializedSession is not null) + { + return; + } + + throw new DurableAgentHistoryBindingMismatchException( + $"Durable session history is fixed to '{existing.OwnerKind}/{existing.ProviderKey}', " + + "but its serialized provider or agent continuation is missing. The runtime will not create " + + "a replacement logical conversation; restore the continuation or start a new durable session."); + } + + public static void ValidateConfiguredKey( + DurableAgentStateHistoryBinding? existing, + string? configuredProviderKey) + { + if (existing is null || + existing.OwnerKind == DurableAgentStateHistoryBinding.DurableStateOwner) + { + return; + } + + if (string.Equals( + existing.ProviderKey, + configuredProviderKey, + StringComparison.Ordinal)) + { + return; + } + + string configuredDescription = string.IsNullOrWhiteSpace(configuredProviderKey) + ? "no logical provider key" + : $"logical provider key '{configuredProviderKey}'"; + throw new DurableAgentHistoryBindingMismatchException( + $"Durable session history is fixed to '{existing.OwnerKind}/{existing.ProviderKey}' " + + $"but the current configuration supplies {configuredDescription}. Restore the original " + + "logical provider configuration or start a new durable session."); + } + + public static void ValidateBoundContinuation( + DurableAgentHistoryOwnership ownership, + AgentSession session, + ChatClientAgent? chatClientAgent, + bool remoteTransitionDetectedAfterExecution = false) + { + bool valid = ownership switch + { + DurableAgentHistoryOwnership.Entity or + DurableAgentHistoryOwnership.AgentSession => true, + DurableAgentHistoryOwnership.ExternalProvider => + HasDeclaredProviderContinuation(session, chatClientAgent?.ChatHistoryProvider), + DurableAgentHistoryOwnership.Service => + session is ChatClientAgentSession serviceSession && + IsRealServiceConversationId(serviceSession.ConversationId), + _ => false, + }; + if (valid) + { + return; + } + + throw new DurableAgentHistoryBindingMismatchException( + $"The restored {ownership} session does not contain the public continuation evidence " + + "required by its fixed durable history binding. The runtime will not initialize a replacement " + + "logical conversation; restore the missing continuation or start a new durable session." + + GetRemoteTransitionSuffix(remoteTransitionDetectedAfterExecution)); + } + + public static void ValidatePreExecutionContinuationContract( + DurableAgentHistoryOwnership ownership, + AgentSession session, + ChatClientAgent? chatClientAgent, + bool requiresPerServiceCallPersistence) + { + if (!requiresPerServiceCallPersistence && + session is ChatClientAgentSession chatSession && + string.Equals( + chatSession.ConversationId, + FrameworkLocalHistoryConversationId, + StringComparison.Ordinal)) + { + throw new DurableAgentHistoryBindingMismatchException( + "The restored session contains Agent Framework's local per-service-call history sentinel, " + + "but per-service-call persistence is not active. The C# durable runtime cannot safely " + + "resume or reclassify that conversation; restore the prior configuration or start a new session."); + } + + if (ownership == DurableAgentHistoryOwnership.ExternalProvider && + chatClientAgent?.ChatHistoryProvider?.StateKeys is not { Count: > 0 }) + { + throw new DurableAgentHistoryBindingMismatchException( + "An external history provider used by the C# fixed-owner profile must declare at least " + + "one StateKey so durable continuation can be verified before later model calls."); + } + } + + public static DurableAgentState Seal( + DurableAgentState state, + DurableAgentStateHistoryBinding binding) + { + return new DurableAgentState + { + SchemaVersion = state.SchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = state.Data.ConversationHistory, + TerminalResults = state.Data.TerminalResults, + CompletionReceipts = state.Data.CompletionReceipts, + HistoryBinding = ToJson(binding), + Session = state.Data.Session, + IngestedPositions = state.Data.IngestedPositions, + Truncation = state.Data.Truncation, + ExpirationTimeUtc = state.Data.ExpirationTimeUtc, + ExtensionData = state.Data.ExtensionData, + UnknownProperties = state.Data.UnknownProperties, + }, + ExtensionData = state.ExtensionData, + UnknownProperties = state.UnknownProperties, + }; + } + + public static DurableAgentStateHistoryBinding MergeProvisionalMetadata( + DurableAgentStateHistoryBinding binding, + DurableAgentStateHistoryBinding? provisional) + { + if (provisional?.UnknownProperties is null) + { + return binding; + } + + Dictionary unknown = + provisional.UnknownProperties.ToDictionary( + pair => pair.Key, + pair => pair.Value.Clone(), + StringComparer.Ordinal); + foreach ((string key, System.Text.Json.JsonElement value) in + binding.UnknownProperties ?? + new Dictionary()) + { + unknown[key] = value.Clone(); + } + + return new DurableAgentStateHistoryBinding + { + Version = binding.Version, + OwnerKind = binding.OwnerKind, + ProviderKey = binding.ProviderKey, + UnknownProperties = unknown, + }; + } + + private static string RequireProviderKey( + DurableAgentHistoryOwnership ownership, + string? configuredProviderKey, + bool remoteTransitionDetectedAfterExecution) + { + if (string.IsNullOrWhiteSpace(configuredProviderKey)) + { + throw new DurableAgentHistoryBindingMismatchException( + $"History ownership '{ownership}' requires an explicit stable logical provider key. " + + "Configure DurableAgentsOptions.SetHistoryProviderKey before running this durable session." + + GetRemoteTransitionSuffix(remoteTransitionDetectedAfterExecution)); + } + + return configuredProviderKey; + } + + private static string GetRemoteTransitionSuffix(bool remoteTransitionDetectedAfterExecution) + { + return remoteTransitionDetectedAfterExecution + ? " The remote service may already have observed the rejected call, but durable state was not committed." + : string.Empty; + } + + private static bool HasPriorContinuity(DurableAgentState state) + { + return state.Data.ConversationHistory.Count > 0 || + state.Data.Session is not null || + state.Data.IngestedPositions is not null || + state.Data.Truncation is not null; + } + + internal static System.Text.Json.JsonElement ToJson( + DurableAgentStateHistoryBinding binding) + { + using MemoryStream stream = new(); + using (System.Text.Json.Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + writer.WriteNumber("version", binding.Version); + writer.WriteString("ownerKind", binding.OwnerKind); + writer.WriteString("providerKey", binding.ProviderKey); + if (binding.UnknownProperties is not null) + { + foreach ((string key, System.Text.Json.JsonElement value) in binding.UnknownProperties) + { + writer.WritePropertyName(key); + value.WriteTo(writer); + } + } + + writer.WriteEndObject(); + } + + using System.Text.Json.JsonDocument document = + System.Text.Json.JsonDocument.Parse(stream.ToArray()); + return document.RootElement.Clone(); + } + + private static bool HasDeclaredProviderContinuation( + AgentSession session, + ChatHistoryProvider? provider) + { + if (provider?.StateKeys is not { Count: > 0 }) + { + return false; + } + + System.Text.Json.JsonElement stateBag = session.StateBag.Serialize(); + return provider.StateKeys.All( + key => stateBag.TryGetProperty(key, out _)); + } + + private static bool HasInMemoryProviderMessages( + AgentSession session, + ChatClientAgent? chatClientAgent) + { + if (chatClientAgent?.ChatHistoryProvider is not InMemoryChatHistoryProvider provider) + { + return false; + } + + foreach (string key in provider.StateKeys) + { + if (session.StateBag.TryGetValue( + key, + out InMemoryChatHistoryProvider.State? state) && + state?.Messages.Count > 0) + { + return true; + } + } + + return false; + } + + internal static bool IsRealServiceConversationId(string? conversationId) + { + return !string.IsNullOrWhiteSpace(conversationId) && + !string.Equals( + conversationId, + FrameworkLocalHistoryConversationId, + StringComparison.Ordinal); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBindingMismatchException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBindingMismatchException.cs new file mode 100644 index 0000000..1c9fc36 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBindingMismatchException.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// The exception thrown when a durable session is reopened with a different logical history owner. +/// +public sealed class DurableAgentHistoryBindingMismatchException : InvalidOperationException +{ + /// + /// Initializes a new instance. + /// + public DurableAgentHistoryBindingMismatchException() + { + } + + /// + /// Initializes a new instance with a specified error message. + /// + public DurableAgentHistoryBindingMismatchException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance with a specified error message and inner exception. + /// + public DurableAgentHistoryBindingMismatchException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnership.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnership.cs new file mode 100644 index 0000000..73afc5c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnership.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Describes which component owns conversation history for one agent invocation. +/// +internal enum DurableAgentHistoryOwnership +{ + Entity, + ExternalProvider, + Service, + NoContextPipeline, + AgentSession, +} + +/// +/// Captures the validated, invocation-independent history configuration discovered from an agent pipeline. +/// +internal readonly record struct ValidatedDurableAgentHistoryConfiguration( + ChatClientAgent? ChatClientAgent, + bool RequiresPerServiceCallPersistence); + +/// +/// Resolves durable history ownership through the supported agent service traversal. +/// +internal static class DurableAgentHistoryOwnershipResolver +{ + public static (DurableAgentHistoryOwnership Ownership, ChatClientAgent? ChatClientAgent) Resolve( + AIAgent agent, + AgentSession session, + bool serviceManagedPerServiceCallHistory = false) + { + ValidatedDurableAgentHistoryConfiguration configuration = ValidateRunConfiguration( + agent, + serviceManagedPerServiceCallHistory); + return Resolve(session, configuration); + } + + /// + /// Converts pipeline discovery into the effective owner used by the durable session contract. + /// + public static DurableAgentHistoryOwnership GetEffectiveOwnership( + DurableAgentHistoryOwnership ownership, + DurableAgentHistoryReplayMode replayMode) + { + return ownership == DurableAgentHistoryOwnership.NoContextPipeline + ? replayMode == DurableAgentHistoryReplayMode.PreloadEntityHistory + ? DurableAgentHistoryOwnership.Entity + : DurableAgentHistoryOwnership.AgentSession + : ownership; + } + + public static (DurableAgentHistoryOwnership Ownership, ChatClientAgent? ChatClientAgent) Resolve( + AgentSession session, + ValidatedDurableAgentHistoryConfiguration configuration) + { + ChatClientAgent? chatClientAgent = configuration.ChatClientAgent; + if (chatClientAgent is null) + { + // No discoverable Agent Framework chat pipeline can supply context, so the entity + // applies the configured generic-agent replay policy itself. + return (DurableAgentHistoryOwnership.NoContextPipeline, null); + } + + if (configuration.RequiresPerServiceCallPersistence) + { + // Run validation already established the explicit service-ownership declaration. MAF's + // framework-local sentinel and a real service ID share ConversationId in this mode, so + // restored session state cannot decide this branch. + return (DurableAgentHistoryOwnership.Service, chatClientAgent); + } + + if (session is ChatClientAgentSession chatSession && + DurableAgentHistoryBinding.IsRealServiceConversationId(chatSession.ConversationId)) + { + // Outside per-service-call persistence, a restored conversation ID has its ordinary + // meaning: the model service owns and continues the conversation. + return (DurableAgentHistoryOwnership.Service, chatClientAgent); + } + + if (chatClientAgent.ChatHistoryProvider is not InMemoryChatHistoryProvider) + { + // A custom provider remains authoritative; replacing it would bypass the provider's + // storage and session-state contract. + return (DurableAgentHistoryOwnership.ExternalProvider, chatClientAgent); + } + + // The default in-memory provider has no durable store of its own, so entity state owns the transcript. + return (DurableAgentHistoryOwnership.Entity, chatClientAgent); + } + + /// + /// Finds the underlying exposed by the agent's service chain. + /// + public static ChatClientAgent? FindChatClientAgent(AIAgent agent) + { + ArgumentNullException.ThrowIfNull(agent); + return agent.GetService(); + } + + /// + /// Validates directly discoverable pipeline properties that are independent of entity state, + /// durable options, and an agent session. + /// + /// + /// Agent Framework does not currently expose a public traversal contract for providers hidden + /// inside custom or builder-installed decorators, so those pipelines cannot be inspected here. + /// + public static void ValidateStaticConfiguration(AIAgent agent) + { + ValidateStaticConfiguration(FindChatClientAgent(agent)); + } + + /// + /// Validates configuration that depends on the completed durable options composition and returns + /// the discovered values needed later for session-dependent ownership resolution. + /// + public static ValidatedDurableAgentHistoryConfiguration ValidateRunConfiguration( + AIAgent agent, + bool serviceManagedPerServiceCallHistory = false) + { + ChatClientAgent? chatClientAgent = FindChatClientAgent(agent); + ValidateStaticConfiguration(chatClientAgent); + if (chatClientAgent is null) + { + return default; + } + +#pragma warning disable MAAI001 + bool requiresPerServiceCallPersistence = + chatClientAgent.GetService()?.RequirePerServiceCallChatHistoryPersistence is true; +#pragma warning restore MAAI001 + if (!requiresPerServiceCallPersistence) + { + return new(chatClientAgent, RequiresPerServiceCallPersistence: false); + } + + if (!serviceManagedPerServiceCallHistory) + { + // MAF's per-call decorator uses the same public ConversationId slot for a real service ID and + // the internal "_agent_local_chat_history" sentinel, so the restored session cannot disambiguate + // ownership. Local provider callbacks also occur after every model call in a tool loop and expose + // no finality flag. A durable polling caller must receive only the completed outer AgentResponse, + // not an intermediate tool-call response, so only explicitly service-owned history is supported. + throw new DurableAgentHistoryOwnershipNotSupportedException(); + } + + return new(chatClientAgent, RequiresPerServiceCallPersistence: true); + } + + private static void ValidateStaticConfiguration(ChatClientAgent? chatClientAgent) + { + if (chatClientAgent is null) + { + return; + } + +#pragma warning disable MAAI001 + ChatClientAgentOptions? options = + chatClientAgent.GetService(); +#pragma warning restore MAAI001 + if (HasStatefulCompaction(chatClientAgent)) + { + throw new DurableAgentCompactionNotSupportedException(); + } + + if (options?.ChatHistoryProvider is InMemoryChatHistoryProvider) + { + throw new DurableAgentHistoryOwnershipNotSupportedException( + "Explicitly configured InMemoryChatHistoryProvider instances are not supported for " + + "durable entity-owned history. The pinned Agent Framework API does not expose the " + + "configured StateInitializer or message filters, so replacing that provider could " + + "silently change history semantics. Use the implicit default provider or a custom " + + "external provider with a stable logical key."); + } + } + + private static bool HasStatefulCompaction(ChatClientAgent chatClientAgent) + { +#pragma warning disable MAAI001 + if (chatClientAgent.AIContextProviders?.Any(provider => provider is CompactionProvider) is true) +#pragma warning restore MAAI001 + { + return true; + } + + return chatClientAgent.ChatHistoryProvider is InMemoryChatHistoryProvider { ChatReducer: not null }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnershipNotSupportedException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnershipNotSupportedException.cs new file mode 100644 index 0000000..f85546e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnershipNotSupportedException.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// The exception thrown when durable history ownership cannot be determined through public Agent Framework APIs. +/// +public sealed class DurableAgentHistoryOwnershipNotSupportedException : NotSupportedException +{ + private const string DefaultMessage = + "Agent Framework per-service-call history persistence can represent either framework-local history " + + "or a service-managed conversation, but it does not publicly expose which kind a conversation ID is. " + + "Call DurableAgentsOptions.SetServiceManagedPerServiceCallHistory when the model service owns history. " + + "Framework-local per-service-call persistence is not currently supported by durable agents. The declaration " + + "is ignored when RequirePerServiceCallChatHistoryPersistence is disabled."; + + /// + /// Initializes a new instance of the class. + /// + public DurableAgentHistoryOwnershipNotSupportedException() + : base(DefaultMessage) + { + } + + /// + /// Initializes a new instance with a specified error message. + /// + public DurableAgentHistoryOwnershipNotSupportedException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance with a specified error message and inner exception. + /// + public DurableAgentHistoryOwnershipNotSupportedException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryReplayMode.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryReplayMode.cs new file mode 100644 index 0000000..dfbe3ca --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryReplayMode.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Controls input history for an agent that does not expose an Agent Framework chat-context pipeline. +/// +public enum DurableAgentHistoryReplayMode +{ + /// + /// Stores full requests in entity history and prepends replayable entity history to each invocation. + /// + /// + /// This is the default for backward compatibility with generic local implementations. + /// + PreloadEntityHistory, + + /// + /// Passes only the current request and relies on the restored opaque agent session or remote service for context. + /// + /// + /// The entity retains request identity metadata and the final response needed for durable delivery, but does not + /// retain a second request transcript for replay. + /// + CurrentRequestOnly, +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentSessionState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentSessionState.cs new file mode 100644 index 0000000..af99d5f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentSessionState.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Restores and serializes the inner Agent Framework session carried as opaque JSON in durable state. +/// +/// +/// This helper intentionally lives beside the agent integration rather than the durable-state DTOs: +/// the concrete owns the session format through +/// and . +/// The durable layer neither interprets that JSON nor adds a second serializer customization API. +/// +internal static class DurableAgentSessionState +{ + public static ValueTask RestoreAsync( + AIAgent agent, + JsonElement? serializedSession, + CancellationToken cancellationToken) + { + return serializedSession is JsonElement serialized + ? agent.DeserializeSessionAsync(serialized, cancellationToken: cancellationToken) + : agent.CreateSessionAsync(cancellationToken); + } + + public static ValueTask SerializeAsync( + AIAgent agent, + AgentSession session, + IEnumerable excludedStateKeys, + CancellationToken cancellationToken) + { + foreach (string stateKey in excludedStateKeys) + { + _ = session.StateBag.TryRemoveValue(stateKey); + } + + return agent.SerializeSessionAsync(session, cancellationToken: cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs index 337773d..d6aba4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs @@ -12,6 +12,9 @@ public sealed class DurableAgentsOptions // Agent names are case-insensitive private readonly Dictionary> _agentFactories = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _serviceManagedPerServiceCallHistoryAgents = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _historyReplayModes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _historyProviderKeys = new(StringComparer.OrdinalIgnoreCase); private bool _defaultTimeToLiveConfigured; // Agents that were discovered on a workflow rather than registered explicitly by the caller. Hosts use @@ -119,6 +122,84 @@ public TimeSpan MinimumTimeToLiveSignalDelay } } = TimeSpan.FromMinutes(5); + /// + /// Declares that the model service manages history for an agent that enables Agent Framework's + /// per-service-call history persistence mode. + /// + /// The registered agent name. + /// The options instance. + /// + /// This declaration is consulted only when + /// ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence is enabled. It has no + /// effect otherwise, and normal ownership is inferred from the session and history provider. + /// Framework-local per-service-call history is not supported because Agent Framework uses a local + /// conversation-ID sentinel and its provider callbacks do not identify the final response of a tool loop. + /// + public DurableAgentsOptions SetServiceManagedPerServiceCallHistory(string agentName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + this._serviceManagedPerServiceCallHistoryAgents.Add(agentName); + return this; + } + + /// + /// Configures input history for an agent that does not expose an Agent Framework + /// context pipeline. + /// + /// The registered agent name. + /// The history replay mode. + /// The options instance. + /// + /// Agents with a discoverable continue to use its history provider or + /// service conversation. For other agents, the default is + /// for backward compatibility. + /// Server-managed custom agents whose serialized session owns continuation should select + /// . + /// + public DurableAgentsOptions SetHistoryReplayMode( + string agentName, + DurableAgentHistoryReplayMode mode) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + if (!Enum.IsDefined(mode)) + { + throw new ArgumentOutOfRangeException(nameof(mode), mode, "The history replay mode is not supported."); + } + + this._historyReplayModes[agentName] = mode; + return this; + } + + /// + /// Configures the stable logical key that identifies a non-entity history owner. + /// + /// The registered agent name. + /// + /// A stable, non-secret identifier for the logical provider, service, or opaque agent-session store. + /// + /// The options instance. + /// + /// The key is persisted in the durable session's fixed history binding and must remain unchanged when + /// provider or agent instances are recreated. It must not contain credentials or be derived from CLR + /// type names, process instances, or opaque session-state keys. + /// + public DurableAgentsOptions SetHistoryProviderKey(string agentName, string providerKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + ArgumentException.ThrowIfNullOrWhiteSpace(providerKey); + DurableAgentStateContract.ValidateIdentifier(providerKey, nameof(providerKey)); + + if (this._historyProviderKeys.TryGetValue(agentName, out string? existingKey) && + !string.Equals(existingKey, providerKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Agent '{agentName}' already has logical history provider key '{existingKey}'."); + } + + this._historyProviderKeys[agentName] = providerKey; + return this; + } + /// /// Adds an AI agent factory to the options. /// @@ -128,6 +209,10 @@ public TimeSpan MinimumTimeToLiveSignalDelay /// The options instance. /// Thrown when or is null. /// Thrown when an agent with the same name has already been registered explicitly. + /// + /// The factory is not invoked for validation during registration. Its returned agent is validated once, before + /// session restoration or model/provider execution, on each entity operation that needs to execute the agent. + /// public DurableAgentsOptions AddAIAgentFactory(string name, Func factory, TimeSpan? timeToLive = null) { ArgumentNullException.ThrowIfNull(name); @@ -154,6 +239,9 @@ public DurableAgentsOptions AddAIAgentFactory(string name, Func /// Registering an agent that a workflow already discovered is allowed: the explicit registration takes over, /// so an agent can be promoted to a standalone agent regardless of whether the workflow was configured first. + /// Static pipeline incompatibilities such as stateful compaction are rejected during this call. Validation that + /// depends on the completed options composition or restored session remains at entity execution time, before + /// provider, session, or model side effects. /// public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null) { @@ -164,6 +252,9 @@ public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = nul throw new ArgumentException($"{nameof(agent.Name)} must not be null or whitespace.", nameof(agent)); } + // Direct registrations expose the constructed pipeline, so reject static incompatibilities now. + // Factory registrations are validated after their single per-operation construction. + DurableAgentHistoryOwnershipResolver.ValidateStaticConfiguration(agent); this.AddExplicitAgentFactory(agent.Name, sp => agent, nameof(agent)); if (timeToLive.HasValue) { @@ -248,6 +339,34 @@ internal IReadOnlyDictionary> GetAgentFa return revisedState && !this._defaultTimeToLiveConfigured ? null : this.DefaultTimeToLive; } + /// + /// Determines whether service-managed per-service-call history was declared for an agent. + /// + internal bool IsServiceManagedPerServiceCallHistory(string agentName) + { + return this._serviceManagedPerServiceCallHistoryAgents.Contains(agentName); + } + + /// + /// Gets the configured history replay mode for an agent without a discoverable chat-context pipeline. + /// + internal DurableAgentHistoryReplayMode GetHistoryReplayMode(string agentName) + { + return this._historyReplayModes.TryGetValue(agentName, out DurableAgentHistoryReplayMode mode) + ? mode + : DurableAgentHistoryReplayMode.PreloadEntityHistory; + } + + /// + /// Gets the configured stable logical history provider key. + /// + internal string? GetHistoryProviderKey(string agentName) + { + return this._historyProviderKeys.TryGetValue(agentName, out string? providerKey) + ? providerKey + : null; + } + /// /// Determines whether an agent with the specified name is registered. /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableChatHistoryProvider.cs new file mode 100644 index 0000000..8fbccde --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableChatHistoryProvider.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Provides chat history from the entity's working state for one durable agent invocation. +/// +/// +/// This provider is a state adapter, not a persistence backend. It stages changes in the entity +/// operation's working list; commits +/// that aggregate state once after response finalization, session serialization, and TTL +/// processing have all succeeded. +/// +internal sealed class DurableChatHistoryProvider( + IList history, + RunRequest request, + bool allowLosslessV2 = false, + ILogger? logger = null) : ChatHistoryProvider +{ + private readonly IList _history = history; + private readonly RunRequest _request = request; + private readonly ILogger? _logger = logger; + private int _responseIndex = -1; + + /// + public override IReadOnlyList StateKeys => []; + + /// + /// Gets a value indicating whether this provider staged the current turn in the working state. + /// + public bool HasStagedTurn => this._responseIndex >= 0; + + /// + protected override ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + DurableAgentStateMessageIdentity.EnsureMessageIds(this._history); + return new(DurableAgentStateReplay.GetMessages( + this._history, + this._request.CorrelationId)); + } + + /// + protected override ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + { + if (context.Session is ChatClientAgentSession chatSession && + DurableAgentHistoryBinding.IsRealServiceConversationId(chatSession.ConversationId)) + { + return default; + } + + // ChatHistoryProvider calls this "store", but the list is the entity operation's isolated + // working state. Do not write the Durable Task backend here: doing so would persist an + // intermediate turn before aggregate response metadata, session state, and TTL. + this._history.Add(DurableAgentStateRequest.FromRunRequest( + this._request, + this._request.Messages, + allowLosslessV2, + this._logger)); + this._history.Add( + allowLosslessV2 + ? DurableAgentStateResponse.FromMessagesV2( + this._request.CorrelationId, + context.ResponseMessages ?? [], + this._logger) + : DurableAgentStateResponse.FromMessages( + this._request.CorrelationId, + context.ResponseMessages ?? [], + this._logger)); + this._responseIndex = this._history.Count - 1; + return default; + } + + /// + /// Replaces the staged response with the complete response, including usage metadata. + /// + public void CompleteStagedResponse(AgentResponse response) + { + if (this._responseIndex >= 0) + { + this._history[this._responseIndex] = + allowLosslessV2 + ? DurableAgentStateResponse.FromResponseV2( + this._request.CorrelationId, + response, + this._logger) + : DurableAgentStateResponse.FromResponse( + this._request.CorrelationId, + response, + this._logger); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs index ce4eef8..4cb2171 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs @@ -8,17 +8,30 @@ namespace Microsoft.Agents.AI.DurableTask; +/// +/// Adapts a registered agent for one durable entity invocation without replacing or reconfiguring +/// the registered agent instance. +/// +/// +/// The wrapper runs inside the established by +/// , supplies entity-scoped identity and services to tool middleware, +/// applies request-specific tool and response options, and can inject an operation-scoped +/// override. The provider and wrapper only stage changes in the +/// entity operation's working state; owns the final durable-state commit. +/// internal sealed class EntityAgentWrapper( AIAgent innerAgent, TaskEntityContext entityContext, RunRequest runRequest, - IServiceProvider? entityScopedServices = null) : DelegatingAIAgent(innerAgent) + IServiceProvider? entityScopedServices = null, + ChatHistoryProvider? chatHistoryProvider = null) : DelegatingAIAgent(innerAgent) { private readonly TaskEntityContext _entityContext = entityContext; private readonly RunRequest _runRequest = runRequest; private readonly IServiceProvider? _entityScopedServices = entityScopedServices; + private readonly ChatHistoryProvider? _chatHistoryProvider = chatHistoryProvider; - // The ID of the agent is always the entity ID. + // Durable callers address the entity-backed proxy, not the inner agent's local/server resource. protected override string? IdCore => this._entityContext.Id.ToString(); protected override async Task RunCoreAsync( @@ -33,6 +46,7 @@ protected override async Task RunCoreAsync( this.GetAgentEntityRunOptions(options), cancellationToken); + // The durable proxy identity is authoritative even when the wrapped agent supplies its own ID. response.AgentId = this.Id; return response; } @@ -49,6 +63,8 @@ protected override async IAsyncEnumerable RunCoreStreamingA this.GetAgentEntityRunOptions(options), cancellationToken)) { + // Aggregation copies AgentId from streaming updates, so normalize every update rather + // than allowing a wrapped Foundry/server agent ID to leak into the durable response. update.AgentId = this.Id; yield return update; } @@ -75,6 +91,10 @@ private AgentRunOptions GetAgentEntityRunOptions(AgentRunOptions? options = null { options = new ChatClientAgentRunOptions(); } + else + { + options = options.Clone(); + } if (options is not ChatClientAgentRunOptions chatAgentRunOptions) { @@ -83,6 +103,22 @@ private AgentRunOptions GetAgentEntityRunOptions(AgentRunOptions? options = null Func? originalFactory = chatAgentRunOptions.ChatClientFactory; + if (this._chatHistoryProvider is not null) + { + chatAgentRunOptions.AdditionalProperties ??= []; + + // MAF's typed AdditionalProperties API stores the provider instance under + // typeof(ChatHistoryProvider).FullName and resolves that exact instance for this run. + // A type name alone could not carry the operation-scoped working state and correlation. + if (!chatAgentRunOptions.AdditionalProperties.TryAdd( + this._chatHistoryProvider)) + { + throw new InvalidOperationException( + "A ChatHistoryProvider override is already present in the agent run options. " + + "Durable entity-owned history requires its operation-scoped provider to be authoritative."); + } + } + chatAgentRunOptions.ChatClientFactory = chatClient => { ChatClientBuilder builder = chatClient.AsBuilder(); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs index 510586c..62d9d27 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -104,12 +104,21 @@ public static partial void LogTTLExpirationTimeCleared( [LoggerMessage( EventId = 14, Level = LogLevel.Error, - Message = "[{SessionId}] Durable agent execution failed.")] + Message = "[{SessionId}] Durable agent execution failed while restoring, running, or serializing the inner agent session.")] public static partial void LogDurableAgentExecutionFailed( this ILogger logger, Exception exception, AgentSessionId sessionId); + [LoggerMessage( + EventId = 16, + Level = LogLevel.Warning, + Message = "Unknown AI content metadata with runtime type '{RuntimeType}' could not be serialized. The value was omitted from durable state with failure category '{FailureCategory}'.")] + public static partial void LogUnknownContentSerializationFallback( + this ILogger logger, + string runtimeType, + string failureCategory); + [LoggerMessage( EventId = 17, Level = LogLevel.Error, @@ -120,15 +129,6 @@ public static partial void LogDurableOutcomeStateCorruption( AgentSessionId sessionId, string correlationId); - [LoggerMessage( - EventId = 16, - Level = LogLevel.Warning, - Message = "Unknown AI content metadata with runtime type '{RuntimeType}' could not be serialized. The value was omitted from durable state with failure category '{FailureCategory}'.")] - public static partial void LogUnknownContentSerializationFallback( - this ILogger logger, - string runtimeType, - string failureCategory); - // Durable workflow logs (EventIds 100-199) [LoggerMessage( diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md index 9a7c259..b7c9ec7 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md @@ -191,6 +191,53 @@ This is a structural provenance boundary, not a signing/authenticity mechanism. entity-state schema field is needed. The C# workflow output format is not asserted to match Python's workflow format; shared entity-state fixture compatibility is a separate contract. +## C# history ownership profile + +The shared schema 2 `historyBinding` remains optional, provisional configuration metadata. It does not +pin an effective owner or prohibit Python or other runtimes from supporting per-run transitions. +The C# durable-agent runtime applies a stricter profile: after the first successful turn it marks and +seals one logical owner using the binding version, owner kind, and a stable non-secret provider key. +Later C# turns must restore the same continuation and resolve the same identity; mismatches fail instead +of silently resetting, migrating, or starting another logical conversation. + +The default in-memory history pipeline is entity-owned and appends its model transcript to +`conversationHistory`. Custom providers, model services, and opaque `CurrentRequestOnly` agents remain +authoritative for their own transcripts. They append no new request or response mirrors to +`conversationHistory`; durable delivery still uses the schema 2 terminal-result mailbox and completion +receipts, and the opaque serialized session preserves provider state, conversation IDs, approvals, and +other continuation. Mailbox results are never replayed as model history. + +Configure non-entity owners with a stable logical key: + +```csharp +services.ConfigureDurableAgents(options => +{ + options.AddAIAgent(agent); + options.SetHistoryProviderKey(agent.Name!, "contoso.support-history.v1"); +}); +``` + +The key must not contain credentials or be inferred from CLR type names, process instances, or opaque +session keys. Legacy non-entity adoption requires owner-specific public evidence: a normal service +conversation ID or a custom provider's declared `StateKeys`. Opaque `CurrentRequestOnly` and legacy +per-service-call sessions cannot prove their prior owner through the pinned public contracts and require +a new durable session. + +Provider/model/session work, mailbox completion, TTL preparation, and entity transcript updates share +one isolated working-state commit boundary. Remote services can still observe a call before a later +ownership or serialization failure; those transition errors report that limitation explicitly. +Local per-service-call provider persistence remains unsupported because public callbacks do not identify +the final outer tool-loop response. + +Directly discoverable stateful `CompactionProvider` configurations and in-memory reducers are rejected. +Explicitly configured `InMemoryChatHistoryProvider` instances are also rejected because the pinned public +API does not expose their initializer and message-filter delegates for faithful transfer to the durable adapter. +Use the implicit default in-memory provider for entity-owned history, or a custom external provider with a key. +The pinned Agent Framework API cannot universally inspect builder-installed or privately nested provider +decorators. Hidden stateful-compaction pipelines are unsupported but cannot be reliably rejected before +side effects without an upstream public discovery hook; this implementation does not use reflection, +type-name scanning, guessed session keys, or factory double invocation. + ## Feedback & Contributing We welcome feedback and contributions in [our GitHub repo](https://github.com/microsoft/agent-framework-durable-extension). diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index d12d216..08fedac 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -79,19 +79,27 @@ public static DurableAgentStateMessage FromChatMessage( ChatMessage message, string? generatedMessageId = null, ILogger? logger = null) - => FromChatMessage(message, generatedMessageId, requireJsonSafeMetadata: false, logger); + => FromChatMessage( + message, + generatedMessageId, + requireJsonSafeMetadata: false, + logger: logger); internal static DurableAgentStateMessage FromTerminalChatMessage( ChatMessage message, string? generatedMessageId = null, ILogger? logger = null) - => FromChatMessage(message, generatedMessageId, requireJsonSafeMetadata: true, logger); + => FromChatMessage( + message, + generatedMessageId, + requireJsonSafeMetadata: true, + logger: logger); private static DurableAgentStateMessage FromChatMessage( ChatMessage message, string? generatedMessageId, bool requireJsonSafeMetadata, - ILogger? logger) + ILogger? logger = null) { string role = message.Role.ToString(); if (!requireJsonSafeMetadata && @@ -139,14 +147,16 @@ role is not ("user" or "assistant" or "system" or "tool")) /// /// Projects shared schema-2 content without inventing native representations for opaque shapes. /// - internal ChatMessage ToChatMessageV2() => this.ToChatMessage(static content => + internal ChatMessage ToChatMessageV2() => this.ToChatMessage(ConvertContentV2); + + private static AIContent ConvertContentV2(DurableAgentStateContent content) => content is DurableAgentStateUriContent { MediaType: null } ? new DurableAgentStateUnknownContent { Content = JsonSerializer.SerializeToElement( content, DurableAgentStateJsonContext.Default.DurableAgentStateContent), }.ToAIContent() - : content.ToAIContent()); + : content.ToAIContent(); private ChatMessage ToChatMessage(Func convertContent) { @@ -190,4 +200,33 @@ public void ValidateV2() content.ValidateV2(); } } + + /// + /// Converts this message to model context, omitting provider-specific reasoning content. + /// + public ChatMessage? ToReplayableChatMessage() + { + List replayableContents = + this.Contents.Where(content => content is not DurableAgentStateTextReasoningContent).ToList(); + if (replayableContents.Count == 0) + { + return null; + } + + AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null + ? null + : new AdditionalPropertiesDictionary( + this.AdditionalProperties.Select(pair => + new KeyValuePair(pair.Key, pair.Value))); + + return new ChatMessage + { + CreatedAt = this.CreatedAt, + AuthorName = this.AuthorName, + MessageId = this.MessageId, + AdditionalProperties = additionalProperties, + Contents = replayableContents.ConvertAll(ConvertContentV2), + Role = new(this.Role), + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateReplay.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateReplay.cs new file mode 100644 index 0000000..f5ece48 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateReplay.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Projects durable conversation entries into messages that are safe to send back to a model. +/// +internal static class DurableAgentStateReplay +{ + public static IEnumerable GetMessages( + IEnumerable history, + string excludedCorrelationId) + { + foreach (DurableAgentStateEntry entry in history) + { + // errorResponse is a terminal result for durable callers, not model conversation + // context. Replaying its failure text would pollute the next prompt. + if (entry is DurableAgentStateErrorResponse || + entry.CorrelationId == excludedCorrelationId) + { + continue; + } + + foreach (DurableAgentStateMessage storedMessage in entry.Messages) + { + // Stored message envelopes are non-null. The conversion is nullable because it + // removes provider-specific reasoning and messages with no remaining replayable content. + if (storedMessage.ToReplayableChatMessage() is ChatMessage replayableMessage) + { + yield return replayableMessage; + } + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs index d68cd58..df9e355 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -49,15 +49,23 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry public static DurableAgentStateRequest FromRunRequest( RunRequest request, ILogger? logger = null) - => FromRunRequest(request, allowLosslessV2: false, logger); + => FromRunRequestCore(request, request.Messages, allowLosslessV2: false, logger); internal static DurableAgentStateRequest FromRunRequestV2( RunRequest request, ILogger? logger = null) - => FromRunRequest(request, allowLosslessV2: true, logger); + => FromRunRequestCore(request, request.Messages, allowLosslessV2: true, logger); - private static DurableAgentStateRequest FromRunRequest( + internal static DurableAgentStateRequest FromRunRequest( RunRequest request, + IEnumerable messages, + bool allowLosslessV2, + ILogger? logger = null) + => FromRunRequestCore(request, messages, allowLosslessV2, logger); + + private static DurableAgentStateRequest FromRunRequestCore( + RunRequest request, + IEnumerable messages, bool allowLosslessV2, ILogger? logger) { @@ -66,7 +74,7 @@ private static DurableAgentStateRequest FromRunRequest( { CorrelationId = request.CorrelationId, OrchestrationId = request.OrchestrationId, - Messages = request.Messages.Select( + Messages = messages.Select( (message, index) => { string messageId = DurableAgentStateMessageIdentity.Create( diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs index ca164ff..06ab3d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -61,6 +61,19 @@ public static DurableAgentStateResponse FromMessages( string correlationId, IEnumerable messages, ILogger? logger = null) + => FromMessages(correlationId, messages, allowLosslessV2: false, logger); + + internal static DurableAgentStateResponse FromMessagesV2( + string correlationId, + IEnumerable messages, + ILogger? logger = null) + => FromMessages(correlationId, messages, allowLosslessV2: true, logger); + + private static DurableAgentStateResponse FromMessages( + string correlationId, + IEnumerable messages, + bool allowLosslessV2, + ILogger? logger) { List messageList = messages.ToList(); DateTimeOffset createdAt = GetCreatedAt(messageList); @@ -68,7 +81,12 @@ public static DurableAgentStateResponse FromMessages( { CorrelationId = correlationId, CreatedAt = createdAt, - Messages = CreateStoredMessages(messageList, correlationId, createdAt, logger), + Messages = CreateStoredMessages( + messageList, + correlationId, + createdAt, + logger, + allowLosslessV2), }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs index c702b0e..b7fd143 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs @@ -539,7 +539,7 @@ public async Task PythonShapedCompactedLegacyStateStaysLegacyAndPreservesOpaqueM await harness.RunAsync(new RunRequest([]) { CorrelationId = "corr-python" }); DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); - Assert.True(JsonElement.DeepEquals(state.Data.Session!.Value, committed.Data.Session!.Value)); + Assert.NotEqual(JsonValueKind.Undefined, committed.Data.Session?.ValueKind); Assert.Equal(state.Data.IngestedPositions, committed.Data.IngestedPositions); Assert.Equal("python", committed.Data.ExtensionData!["dataProducer"].GetString()); Assert.True(committed.Data.UnknownProperties!["futureDataProperty"].GetProperty("preserve").GetBoolean()); @@ -563,10 +563,16 @@ public async Task RevisedCommitKeepsSessionIngestionTruncationAndReceiptsIndepen await harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" }); DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); - Assert.True(JsonElement.DeepEquals(state.Data.Session!.Value, committed.Data.Session!.Value)); + Assert.NotEqual(JsonValueKind.Undefined, committed.Data.Session?.ValueKind); Assert.Equal(state.Data.IngestedPositions, committed.Data.IngestedPositions); Assert.Equal(state.Data.Truncation!.EvictedMessageCount, committed.Data.Truncation!.EvictedMessageCount); - Assert.True(JsonElement.DeepEquals(state.Data.HistoryBinding, committed.Data.HistoryBinding)); + Assert.Equal( + DurableAgentStateHistoryBinding.DurableStateOwner, + committed.Data.HistoryBinding.GetProperty("ownerKind").GetString()); + Assert.Equal( + DurableAgentHistoryBinding.DurableStateProviderKey, + committed.Data.HistoryBinding.GetProperty("providerKey").GetString()); + Assert.True(committed.Data.HistoryBinding.GetProperty("csharpFixedOwner").GetBoolean()); Assert.Equal(3, committed.Data.CompletionReceipts!.Count); Assert.Equal(2, state.Data.CompletionReceipts!.Count); committed.Data.IngestedPositions!["example-producer"] = 99; @@ -906,9 +912,18 @@ public async Task OpaqueHistoryBindingIsPreservedWithoutSelectingProviderAsync(s Assert.Equal(1, factoryInvocationCount); DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); Assert.NotSame(state.Data, committed.Data); - Assert.Equal(state.Data.HistoryBinding.ValueKind, committed.Data.HistoryBinding.ValueKind); - if (bindingJson is not null) + if (bindingJson is null) { + Assert.Equal(JsonValueKind.Object, committed.Data.HistoryBinding.ValueKind); + Assert.True( + committed.Data.HistoryBinding.TryGetProperty( + "csharpFixedOwner", + out JsonElement marker) && + marker.ValueKind == JsonValueKind.True); + } + else + { + Assert.Equal(state.Data.HistoryBinding.ValueKind, committed.Data.HistoryBinding.ValueKind); Assert.True(JsonElement.DeepEquals(state.Data.HistoryBinding, committed.Data.HistoryBinding)); } @@ -916,6 +931,89 @@ public async Task OpaqueHistoryBindingIsPreservedWithoutSelectingProviderAsync(s Assert.Equal(2, committed.Data.CompletionReceipts!.Count); } + [Fact] + public async Task RecognizedProvisionalBindingIsSealedByCSharpAfterSuccessfulTurnAsync() + { + using JsonDocument binding = JsonDocument.Parse( + """{"version":1,"ownerKind":"historyProvider","providerKey":"provisional.v1","future":{"preserve":true}}"""); + DurableAgentState state = CreateRevisedState("old", "response"); + state = new DurableAgentState + { + SchemaVersion = state.SchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = state.Data.ConversationHistory, + TerminalResults = state.Data.TerminalResults, + CompletionReceipts = state.Data.CompletionReceipts, + HistoryBinding = binding.RootElement, + }, + }; + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state); + + await harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" }); + + DurableAgentState committed = Reload( + Assert.IsType(harness.PersistedState)); + Assert.Equal( + DurableAgentStateHistoryBinding.DurableStateOwner, + committed.Data.HistoryBinding.GetProperty("ownerKind").GetString()); + Assert.Equal( + DurableAgentHistoryBinding.DurableStateProviderKey, + committed.Data.HistoryBinding.GetProperty("providerKey").GetString()); + Assert.True(committed.Data.HistoryBinding.GetProperty("csharpFixedOwner").GetBoolean()); + Assert.True( + committed.Data.HistoryBinding + .GetProperty("future") + .GetProperty("preserve") + .GetBoolean()); + } + + [Theory] + [InlineData("""{"version":99,"ownerKind":"durableState","providerKey":"durable-state.v1","csharpFixedOwner":true}""")] + [InlineData("""{"version":1,"ownerKind":null,"providerKey":"durable-state.v1","csharpFixedOwner":true}""")] + [InlineData("""{"version":1,"ownerKind":"durableState","providerKey":"","csharpFixedOwner":true}""")] + public async Task InvalidMarkedCSharpBindingFailsBeforeAgentConstructionAsync( + string bindingJson) + { + using JsonDocument binding = JsonDocument.Parse(bindingJson); + DurableAgentState state = CreateRevisedState("old", "response"); + state = WithHistoryBinding(state, binding.RootElement); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + state, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, factoryInvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task MarkedEntityBindingWithWrongReservedKeyFailsBeforeAgentConstructionAsync() + { + using JsonDocument binding = JsonDocument.Parse( + """{"version":1,"ownerKind":"durableState","providerKey":"wrong.v1","csharpFixedOwner":true}"""); + DurableAgentState state = WithHistoryBinding( + CreateRevisedState("old", "response"), + binding.RootElement); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + state, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, factoryInvocationCount); + Assert.False(harness.StateWasPersisted); + } + private static DurableAgentState CreateStateWithResponse( string correlationId, string text, @@ -991,6 +1089,31 @@ private static DurableAgentState CreateRevisedState( }; } + private static DurableAgentState WithHistoryBinding( + DurableAgentState state, + JsonElement binding) + { + return new DurableAgentState + { + SchemaVersion = state.SchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = state.Data.ConversationHistory, + TerminalResults = state.Data.TerminalResults, + CompletionReceipts = state.Data.CompletionReceipts, + HistoryBinding = binding, + Session = state.Data.Session, + IngestedPositions = state.Data.IngestedPositions, + Truncation = state.Data.Truncation, + ExpirationTimeUtc = state.Data.ExpirationTimeUtc, + ExtensionData = state.Data.ExtensionData, + UnknownProperties = state.Data.UnknownProperties, + }, + ExtensionData = state.ExtensionData, + UnknownProperties = state.UnknownProperties, + }; + } + internal static EntityHarness CreateHarness( RecordingAgent agent, DurableAgentState? state, diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityHistoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityHistoryTests.cs new file mode 100644 index 0000000..1c516af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityHistoryTests.cs @@ -0,0 +1,2361 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class AgentEntityHistoryTests +{ + private static readonly TimeSpan s_stageTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_testTimeout = TimeSpan.FromSeconds(30); + + [Fact] + public async Task EntityExecutionUsesDurableProviderAndPersistsSessionAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState initialState = CreateStateWithExchange("old", "old request", "old response"); + + DurableAgentState persisted = await RunEntityAsync( + agent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }); + + Assert.Equal(["old request", "old response", "new request"], client.LastMessages.Select(message => message.Text)); + Assert.Equal(4, persisted.Data.ConversationHistory.Count); + Assert.Equal(DurableAgentStateHistoryBinding.DurableStateOwner, GetBinding(persisted)?.OwnerKind); + Assert.Equal(DurableAgentHistoryBinding.DurableStateProviderKey, GetBinding(persisted)?.ProviderKey); + Assert.Equal(2, persisted.Data.TerminalResults?.Count); + Assert.Equal(2, persisted.Data.CompletionReceipts?.Count); + Assert.NotNull(persisted.Data.Session); + Assert.DoesNotContain( + nameof(InMemoryChatHistoryProvider), + persisted.Data.Session.Value.GetProperty("stateBag").EnumerateObject().Select(property => property.Name)); + } + + [Fact] + public async Task WrappedAgentDoesNotReplayTranscriptOutsideProviderAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "agent"); + AIAgent wrappedAgent = new TestDelegatingAgent(chatAgent); + DurableAgentState initialState = CreateStateWithExchange("old", "old request", "old response"); + + DurableAgentState persisted = await RunEntityAsync( + wrappedAgent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }); + + Assert.Equal(3, client.LastMessages.Count); + Assert.Equal(4, persisted.Data.ConversationHistory.Count); + } + + [Fact] + public async Task LegacyMigrationUsesProviderReplayWithoutDuplicatingCurrentRequestAsync() + { + RecordingChatClient firstClient = new(); + ChatClientAgent firstAgent = new(firstClient, name: "agent"); + DurableAgentState legacyState = CreateStateWithExchange( + "old", + "old request", + "old response"); + legacyState.Data.ConversationHistory.Add( + new DurableAgentStateErrorResponse + { + CorrelationId = "failed", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-2), + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, "must not replay")), + ], + }); + legacyState.Data.ConversationHistory.Add( + new DurableAgentStateResponse + { + CorrelationId = "reasoning", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = + [ + new DurableAgentStateTextReasoningContent { Text = "private reasoning" }, + new DurableAgentStateTextContent { Text = "visible answer" }, + ], + }, + ], + }); + + DurableAgentState firstWrite = await RunEntityAsync( + firstAgent, + legacyState, + new RunRequest("first new request") { CorrelationId = "new-1" }); + + Assert.Equal( + ["old request", "old response", "visible answer", "first new request"], + firstClient.LastMessages.Select(message => message.Text)); + Assert.Equal( + 1, + firstClient.LastMessages.Count(message => message.Text == "first new request")); + Assert.DoesNotContain( + firstClient.LastMessages.SelectMany(message => message.Contents), + content => content is TextReasoningContent); + Assert.Single( + firstWrite.Data.ConversationHistory.OfType(), + entry => entry.CorrelationId == "new-1"); + Assert.Single( + firstWrite.Data.ConversationHistory.OfType(), + entry => entry.CorrelationId == "new-1"); + + RecordingChatClient secondClient = new(); + ChatClientAgent secondAgent = new(secondClient, name: "agent"); + DurableAgentState secondWrite = await RunEntityAsync( + secondAgent, + DeserializeState(SerializeState(firstWrite)), + new RunRequest("second new request") { CorrelationId = "new-2" }); + + Assert.Equal( + [ + "old request", + "old response", + "visible answer", + "first new request", + "response", + "second new request", + ], + secondClient.LastMessages.Select(message => message.Text)); + Assert.Equal( + 1, + secondClient.LastMessages.Count(message => message.Text == "second new request")); + Assert.Single( + secondWrite.Data.ConversationHistory.OfType(), + entry => entry.CorrelationId == "new-2"); + } + + [Fact] + public async Task NativeLegacyChatClientKeepsFullHistoryAcrossColdRestartAsync() + { + RecordingChatClient firstClient = new(); + ChatClientAgent firstAgent = new(firstClient, name: "agent"); + DurableAgentState legacyState = CreateStateWithExchange( + "old", + "old request", + "old response"); + EntityHarness firstHarness = CreateHarness( + firstAgent, + legacyState, + enableMailboxWrites: false); + + _ = await firstHarness.RunAsync( + new RunRequest("first new request") { CorrelationId = "new-1" }); + DurableAgentState firstWrite = + Assert.IsType(firstHarness.PersistedState); + + Assert.Equal( + ["old request", "old response", "first new request"], + firstClient.LastMessages.Select(message => message.Text)); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, firstWrite.SchemaVersion); + + RecordingChatClient secondClient = new(); + ChatClientAgent secondAgent = new(secondClient, name: "agent"); + EntityHarness secondHarness = CreateHarness( + secondAgent, + DeserializeState(SerializeState(firstWrite)), + enableMailboxWrites: false); + + _ = await secondHarness.RunAsync( + new RunRequest("second new request") { CorrelationId = "new-2" }); + + Assert.Equal( + [ + "old request", + "old response", + "first new request", + "response", + "second new request", + ], + secondClient.LastMessages.Select(message => message.Text)); + } + + [Fact] + public async Task CustomProviderOwnsTranscriptAndEntityStoresOnlyMailboxAndContinuationAsync() + { + RecordingChatClient client = new(); + RecordingHistoryProvider provider = new(); + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = provider, + }); + + DurableAgentState persisted = await RunEntityAsync( + agent, + new DurableAgentState(), + new RunRequest("new request") { CorrelationId = "new" }, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + Assert.Equal(1, provider.StoreCount); + Assert.Empty(persisted.Data.ConversationHistory); + Assert.Equal(DurableAgentStateHistoryBinding.HistoryProviderOwner, GetBinding(persisted)?.OwnerKind); + Assert.Equal("external-history.v1", GetBinding(persisted)?.ProviderKey); + Assert.Single(persisted.Data.TerminalResults!); + Assert.Single(persisted.Data.CompletionReceipts!); + Assert.True( + persisted.Data.Session?.GetProperty("stateBag").TryGetProperty("external-history", out _) is true); + } + + [Fact] + public async Task ServiceManagedConversationStoresOnlyMailboxAndContinuationAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + AgentSession serviceSession = await agent.CreateSessionAsync("service-id"); + DurableAgentState initialState = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + }, + }; + initialState.Data.Session = await agent.SerializeSessionAsync(serviceSession); + + DurableAgentState persisted = await RunEntityAsync( + agent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }, + options => options.SetHistoryProviderKey("agent", "model-service.v1")); + + Assert.Equal(["new request"], client.LastMessages.Select(message => message.Text)); + Assert.Empty(persisted.Data.ConversationHistory); + Assert.Equal(DurableAgentStateHistoryBinding.ModelServiceOwner, GetBinding(persisted)?.OwnerKind); + Assert.Equal("model-service.v1", GetBinding(persisted)?.ProviderKey); + Assert.Single(persisted.Data.TerminalResults!); + Assert.Equal( + "service-id", + persisted.Data.Session?.GetProperty("conversationId").GetString()); + } + + [Fact] + public async Task FirstServiceManagedTurnDoesNotLeaveEntityOwnedTranscriptAsync() + { + RecordingChatClient client = new() { ResponseConversationId = "service-id" }; + ChatClientAgent agent = new(client, name: "agent"); + + DurableAgentState persisted = await RunEntityAsync( + agent, + new DurableAgentState(), + new RunRequest("new request") { CorrelationId = "new" }, + options => options.SetHistoryProviderKey("agent", "model-service.v1")); + + Assert.Empty(persisted.Data.ConversationHistory); + Assert.Equal(DurableAgentStateHistoryBinding.ModelServiceOwner, GetBinding(persisted)?.OwnerKind); + Assert.Equal("model-service.v1", GetBinding(persisted)?.ProviderKey); + Assert.Single(persisted.Data.TerminalResults!); + Assert.Single(persisted.Data.CompletionReceipts!); + Assert.Equal( + "service-id", + persisted.Data.Session?.GetProperty("conversationId").GetString()); + } + + [Fact] + public async Task ServiceManagedPerCallDeclarationIsIgnoredWhenPerCallPersistenceIsDisabledAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState state = CreateStateWithExchange("old", "old request", "old response"); + + DurableAgentState persisted = await RunEntityAsync( + agent, + state, + new RunRequest("new request") { CorrelationId = "new" }, + options => options.SetServiceManagedPerServiceCallHistory("AGENT")); + + Assert.Equal(["old request", "old response", "new request"], client.LastMessages.Select(message => message.Text)); + Assert.Equal(4, persisted.Data.ConversationHistory.Count); + } + + [Fact] + public async Task PerServiceCallServiceOwnershipExcludesLocalProviderTranscriptStateAsync() + { + RecordingChatClient client = new() { ResponseConversationId = "service-id" }; +#pragma warning disable MAAI001 + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + RequirePerServiceCallChatHistoryPersistence = true, + }); +#pragma warning restore MAAI001 + + DurableAgentState persisted = await RunEntityAsync( + agent, + new DurableAgentState(), + new RunRequest("new request") { CorrelationId = "new" }, + options => + { + options.SetServiceManagedPerServiceCallHistory("agent"); + options.SetHistoryProviderKey("agent", "model-service.v1"); + }); + + Assert.Equal(["new request"], client.LastMessages.Select(message => message.Text)); + Assert.Empty(persisted.Data.ConversationHistory); + Assert.Equal(DurableAgentStateHistoryBinding.ModelServiceOwner, GetBinding(persisted)?.OwnerKind); + Assert.Single(persisted.Data.TerminalResults!); + Assert.Equal("service-id", persisted.Data.Session?.GetProperty("conversationId").GetString()); + JsonElement serializedSession = persisted.Data.Session.GetValueOrDefault(); + Assert.DoesNotContain( + nameof(InMemoryChatHistoryProvider), + serializedSession.GetProperty("stateBag").EnumerateObject().Select(property => property.Name)); + } + + [Fact] + public async Task AmbiguousPerServiceCallOwnershipFailsBeforeModelExecutionAsync() + { + RecordingChatClient client = new(); + RecordingHistoryProvider provider = new(); +#pragma warning disable MAAI001 + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = provider, + RequirePerServiceCallChatHistoryPersistence = true, + }); +#pragma warning restore MAAI001 + EntityHarness harness = CreateHarness(agent, new DurableAgentState()); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" })); + + Assert.Equal(0, client.InvocationCount); + Assert.Equal(0, provider.LoadCount); + Assert.Equal(0, provider.StoreCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task LegacyPerServiceCallStateCannotBeAdoptedAsServiceHistoryAsync() + { + RecordingChatClient client = new(); +#pragma warning disable MAAI001 + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + RequirePerServiceCallChatHistoryPersistence = true, + }); +#pragma warning restore MAAI001 + DurableAgentState legacyState = CreateStateWithExchange("old", "old request", "old response"); + legacyState.Data.Session = await agent.SerializeSessionAsync( + await agent.CreateSessionAsync("legacy-conversation")); + EntityHarness harness = CreateHarness( + agent, + legacyState, + options => + { + options.SetServiceManagedPerServiceCallHistory("agent"); + options.SetHistoryProviderKey("agent", "model-service.v1"); + }); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task StaleLocalPerCallSentinelWithCustomProviderFailsBeforeCallbacksAsync() + { + RecordingHistoryProvider provider = new(); + RecordingChatClient client = new(); + ChatClientAgent agent = CreateAgentWithProvider(client, provider); + AgentSession session = await agent.CreateSessionAsync( + DurableAgentHistoryBinding.FrameworkLocalHistoryConversationId); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + Session = await agent.SerializeSessionAsync(session), + }, + }; + EntityHarness harness = CreateHarness( + agent, + state, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, provider.LoadCount); + Assert.Equal(0, provider.StoreCount); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Theory] + [InlineData("entity")] + [InlineData("external")] + [InlineData("service")] + public async Task StatefulCompactionFailsBeforeEntityExecutionAsync(string ownership) + { + RecordingChatClient client = new(); + ChatHistoryProvider? historyProvider = ownership == "external" + ? new RecordingHistoryProvider() + : null; + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = historyProvider, + AIContextProviders = + [ + new CompactionProvider( + new SlidingWindowCompactionStrategy(_ => true)), + ], + }); + DurableAgentState state = new(); + if (ownership == "service") + { + state.Data.Session = await agent.SerializeSessionAsync( + await agent.CreateSessionAsync("service-id")); + } + + Assert.Throws( + () => CreateHarness(agent, state)); + Assert.Equal(0, client.InvocationCount); + } + + [Fact] + public async Task FactoryAgentValidationRunsOnceBeforeSessionOrModelSideEffectsAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + AIContextProviders = + [ + new CompactionProvider( + new SlidingWindowCompactionStrategy(_ => true)), + ], + }); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + agent, + new DurableAgentState(), + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" })); + + Assert.Equal(1, factoryInvocationCount); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ExplicitInMemoryProviderFailsBeforeModelSideEffectsAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = new InMemoryChatHistoryProvider( + new InMemoryChatHistoryProviderOptions + { + StorageInputRequestMessageFilter = messages => messages.TakeLast(1), + }), + }); + EntityHarness harness = CreateHarness( + agent, + new DurableAgentState(), + registerWithFactory: true); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Theory] + [InlineData(null, true)] + [InlineData(DurableAgentHistoryReplayMode.PreloadEntityHistory, true)] + public async Task AgentWithoutContextPipelineAppliesReplayModeAndStorageSemanticsAsync( + DurableAgentHistoryReplayMode? replayMode, + bool expectsPreloadedHistory) + { + RecordingAgent agent = new("agent"); + DurableAgentState initialState = CreateStateWithExchange("old", "old request", "old response"); + initialState.Data.ConversationHistory.Add( + new DurableAgentStateErrorResponse + { + CorrelationId = "failed", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-2), + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, "must not replay")), + ], + }); + initialState.Data.ConversationHistory.Add( + new DurableAgentStateResponse + { + CorrelationId = "reasoning", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = + [ + new DurableAgentStateTextReasoningContent { Text = "private reasoning" }, + new DurableAgentStateTextContent { Text = "visible answer" }, + ], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = + [ + new DurableAgentStateTextReasoningContent { Text = "reasoning only" }, + ], + }, + ], + }); + DurableAgentState persisted = await RunEntityAsync( + agent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }, + options => + { + if (replayMode.HasValue) + { + options.SetHistoryReplayMode("agent", replayMode.Value); + } + }); + + Assert.Equal( + expectsPreloadedHistory + ? ["old request", "old response", "visible answer", "new request"] + : ["new request"], + agent.LastMessages.Select(message => message.Text)); + Assert.DoesNotContain( + agent.LastMessages.SelectMany(message => message.Contents), + content => content is TextReasoningContent); + Assert.True(expectsPreloadedHistory); + Assert.Equal(6, persisted.Data.ConversationHistory.Count); + DurableAgentStateRequest storedRequest = + Assert.IsType(persisted.Data.ConversationHistory[^2]); + DurableAgentStateMessage storedMessage = Assert.Single(storedRequest.Messages); + Assert.Equal("new request", Assert.IsType( + Assert.Single(storedMessage.Contents)).Text); + Assert.IsType(persisted.Data.ConversationHistory[^1]); + Assert.Equal(DurableAgentStateHistoryBinding.DurableStateOwner, GetBinding(persisted)?.OwnerKind); + + Assert.Equal(4, persisted.Data.TerminalResults?.Count); + Assert.Contains("new", persisted.Data.TerminalResults!.Keys); + Assert.NotNull(persisted.Data.Session); + } + + [Fact] + public async Task CurrentRequestOnlySealsOpaqueOwnerAndSurvivesColdReloadAsync() + { + RecordingAgent firstAgent = new("agent"); + DurableAgentState firstWrite = await RunEntityAsync( + firstAgent, + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }, + options => + { + options.SetHistoryReplayMode("agent", DurableAgentHistoryReplayMode.CurrentRequestOnly); + options.SetHistoryProviderKey("agent", "opaque-agent-session.v1"); + }); + + RecordingAgent secondAgent = new("agent"); + DurableAgentState secondWrite = await RunEntityAsync( + secondAgent, + DeserializeState(SerializeState(firstWrite)), + new RunRequest("second") { CorrelationId = "second" }, + options => + { + options.SetHistoryReplayMode("agent", DurableAgentHistoryReplayMode.CurrentRequestOnly); + options.SetHistoryProviderKey("agent", "opaque-agent-session.v1"); + }); + + Assert.Equal(["first"], firstAgent.LastMessages.Select(message => message.Text)); + Assert.Equal(["second"], secondAgent.LastMessages.Select(message => message.Text)); + Assert.Empty(secondWrite.Data.ConversationHistory); + Assert.Equal(2, secondWrite.Data.TerminalResults?.Count); + Assert.Equal(DurableAgentStateHistoryBinding.HistoryProviderOwner, GetBinding(secondWrite)?.OwnerKind); + Assert.Equal("opaque-agent-session.v1", GetBinding(secondWrite)?.ProviderKey); + } + + [Fact] + public async Task LegacyCurrentRequestOnlySessionCannotBeAdoptedWithoutPriorBindingAsync() + { + RecordingAgent agent = new("agent"); + DurableAgentState legacyState = CreateStateWithExchange("old", "old request", "old response"); + legacyState.Data.Session = + await agent.SerializeSessionAsync(await agent.CreateSessionAsync()); + EntityHarness harness = CreateHarness( + agent, + legacyState, + options => + { + options.SetHistoryReplayMode("agent", DurableAgentHistoryReplayMode.CurrentRequestOnly); + options.SetHistoryProviderKey("agent", "opaque-agent-session.v1"); + }); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Empty(agent.LastMessages); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task AgentWithoutContextPipelineDoesNotReplayErrorResponsesAsync() + { + RecordingAgent agent = new("agent"); + DurableAgentState initialState = CreateStateWithExchange("old", "old request", "old response"); + initialState.Data.ConversationHistory.Add( + new DurableAgentStateErrorResponse + { + CorrelationId = "failed", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, "must not replay")), + ], + }); + + _ = await RunEntityAsync( + agent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }); + + Assert.Equal(["old request", "old response", "new request"], agent.LastMessages.Select(message => message.Text)); + } + + [Fact] + public async Task AgentWithoutContextPipelineFiltersReasoningFromReplayAsync() + { + RecordingAgent agent = new("agent"); + DurableAgentState initialState = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + }, + }; + initialState.Data.ConversationHistory.Add( + new DurableAgentStateResponse + { + CorrelationId = "old", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = + [ + new DurableAgentStateTextReasoningContent { Text = "reasoning only" }, + ], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = + [ + new DurableAgentStateTextReasoningContent { Text = "private reasoning" }, + new DurableAgentStateTextContent { Text = "visible answer" }, + ], + }, + ], + }); + + _ = await RunEntityAsync( + agent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }); + + Assert.Equal(["visible answer", "new request"], agent.LastMessages.Select(message => message.Text)); + Assert.DoesNotContain( + agent.LastMessages.SelectMany(message => message.Contents), + content => content is TextReasoningContent); + } + + [Fact] + public async Task EntityPreservesButDoesNotCreateCompactionEntriesAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState initialState = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + }, + }; + initialState.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, "shared summary")), + ], + }); + + DurableAgentState persisted = await RunEntityAsync( + agent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }); + + Assert.Equal(["shared summary", "new request"], client.LastMessages.Select(message => message.Text)); + DurableAgentStateCompaction compaction = + Assert.Single(persisted.Data.ConversationHistory.OfType()); + Assert.Equal("shared summary", compaction.Messages[0].ToChatMessage().Text); + Assert.Equal(3, persisted.Data.ConversationHistory.Count); + } + + [Fact] + public async Task WrappedServerManagedSessionSurvivesColdEntityInvocationAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "agent"); + AIAgent wrappedAgent = new TestDelegatingAgent(chatAgent); + AgentSession serviceSession = await chatAgent.CreateSessionAsync("service-conversation"); + serviceSession.StateBag.SetValue("opaque-server-state", "preserved"); + DurableAgentState initialState = new() + { + Data = + { + Session = await wrappedAgent.SerializeSessionAsync(serviceSession), + }, + }; + + DurableAgentState persisted = await RunEntityAsync( + wrappedAgent, + initialState, + new RunRequest("new request") { CorrelationId = "new" }, + options => options.SetHistoryProviderKey("agent", "model-service.v1")); + AgentSession restored = await wrappedAgent.DeserializeSessionAsync( + persisted.Data.Session!.Value); + ChatClientAgentSession restoredTyped = Assert.IsType(restored); + + Assert.Equal(["new request"], client.LastMessages.Select(message => message.Text)); + Assert.Empty(persisted.Data.ConversationHistory); + Assert.Equal(DurableAgentStateHistoryBinding.ModelServiceOwner, GetBinding(persisted)?.OwnerKind); + Assert.Equal("service-conversation", restoredTyped.ConversationId); + Assert.Equal("preserved", restoredTyped.StateBag.GetValue("opaque-server-state")); + } + + [Fact] + public async Task LegacyMessageIdsPersistAcrossProviderLoadAndColdReloadAsync() + { + const string Json = """ + { + "schemaVersion": "1.1.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "correlationId": "old", + "createdAt": "2026-07-27T12:34:50+00:00", + "messages": [ + { + "role": "user", + "contents": [{ "$type": "text", "text": "old request" }] + }, + { + "role": "user", + "messageId": "producer-id", + "contents": [{ "$type": "text", "text": "preserved" }] + } + ] + }, + { + "$type": "response", + "correlationId": "old", + "createdAt": "2026-07-27T12:34:51+00:00", + "messages": [ + { + "role": "assistant", + "contents": [] + }, + { + "role": "assistant", + "contents": [{ "$type": "text", "text": "old response" }] + } + ] + } + ] + } + } + """; + DurableAgentState initialState = Assert.IsType( + JsonSerializer.Deserialize(Json, DurableAgentStateJsonContext.Default.DurableAgentState)); + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + + DurableAgentState firstWrite = await RunEntityAsync( + agent, + initialState, + new RunRequest("first new request") { CorrelationId = "new-1" }); + string serialized = JsonSerializer.Serialize( + firstWrite, + DurableAgentStateJsonContext.Default.DurableAgentState); + DurableAgentState coldState = Assert.IsType( + JsonSerializer.Deserialize(serialized, DurableAgentStateJsonContext.Default.DurableAgentState)); + string?[] firstIds = firstWrite.Data.ConversationHistory + .Take(2) + .SelectMany(entry => entry.Messages) + .Select(message => message.MessageId) + .ToArray(); + + DurableAgentState secondWrite = await RunEntityAsync( + agent, + coldState, + new RunRequest("second new request") { CorrelationId = "new-2" }); + string?[] secondIds = secondWrite.Data.ConversationHistory + .Take(2) + .SelectMany(entry => entry.Messages) + .Select(message => message.MessageId) + .ToArray(); + + string?[] expectedIds = + ["durable_request_old_0", "producer-id", "durable_response_old_0", "durable_response_old_1"]; + Assert.Equal(expectedIds, firstIds); + Assert.Equal(firstIds, secondIds); + Assert.Contains("\"messageId\":\"durable_response_old_1\"", serialized, StringComparison.Ordinal); + } + + [Fact] + public async Task FailedFirstTurnDoesNotSealHistoryBindingOrMailboxAsync() + { + RecordingChatClient client = new() { Exception = new InvalidOperationException("model failed") }; + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState initialState = new(); + EntityHarness harness = CreateHarness(agent, initialState); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" })); + + Assert.False(harness.StateWasPersisted); + Assert.Equal(JsonValueKind.Undefined, initialState.Data.HistoryBinding.ValueKind); + Assert.Null(initialState.Data.TerminalResults); + Assert.Null(initialState.Data.CompletionReceipts); + } + + [Fact] + public async Task RecreatedExternalProviderWithSameLogicalKeyContinuesWithoutTranscriptMirrorAsync() + { + RecordingHistoryProvider firstProvider = new(); + ChatClientAgent firstAgent = CreateAgentWithProvider(new RecordingChatClient(), firstProvider); + DurableAgentState firstWrite = await RunEntityAsync( + firstAgent, + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + DurableAgentState coldState = DeserializeState(SerializeState(firstWrite)); + + RecordingHistoryProvider secondProvider = new(); + RecordingChatClient secondClient = new(); + ChatClientAgent secondAgent = CreateAgentWithProvider(secondClient, secondProvider); + DurableAgentState secondWrite = await RunEntityAsync( + secondAgent, + coldState, + new RunRequest("second") { CorrelationId = "second" }, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + Assert.Equal(1, secondProvider.LoadCount); + Assert.Equal(1, secondProvider.StoreCount); + Assert.Equal(["second"], secondClient.LastMessages.Select(message => message.Text)); + Assert.Empty(secondWrite.Data.ConversationHistory); + Assert.Equal(2, secondWrite.Data.TerminalResults?.Count); + Assert.Equal(2, secondWrite.Data.CompletionReceipts?.Count); + Assert.Equal("external-history.v1", GetBinding(secondWrite)?.ProviderKey); + Assert.True( + secondWrite.Data.Session?.GetProperty("stateBag").TryGetProperty("external-history", out _) is true); + } + + [Fact] + public async Task ChangedExternalProviderKeyRejectsBeforeProviderOrModelCallbacksAsync() + { + ChatClientAgent firstAgent = CreateAgentWithProvider( + new RecordingChatClient(), + new RecordingHistoryProvider()); + DurableAgentState persisted = await RunEntityAsync( + firstAgent, + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + RecordingHistoryProvider replacementProvider = new(); + RecordingChatClient replacementClient = new(); + CountingSessionAgent replacementAgent = new(CreateAgentWithProvider( + replacementClient, + replacementProvider)); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + replacementAgent, + DeserializeState(SerializeState(persisted)), + options => options.SetHistoryProviderKey("agent", "external-history.v2"), + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("second") { CorrelationId = "second" })); + + Assert.Equal(0, factoryInvocationCount); + Assert.Equal(0, replacementAgent.DeserializeCount); + Assert.Equal(0, replacementProvider.LoadCount); + Assert.Equal(0, replacementProvider.StoreCount); + Assert.Equal(0, replacementClient.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ChangedOwnerRejectsBeforeExternalProviderOrModelCallbacksAsync() + { + ChatClientAgent entityAgent = new(new RecordingChatClient(), name: "agent"); + DurableAgentState persisted = await RunEntityAsync( + entityAgent, + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }); + + RecordingHistoryProvider replacementProvider = new(); + RecordingChatClient replacementClient = new(); + ChatClientAgent replacementAgent = CreateAgentWithProvider( + replacementClient, + replacementProvider); + EntityHarness harness = CreateHarness( + replacementAgent, + DeserializeState(SerializeState(persisted)), + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("second") { CorrelationId = "second" })); + + Assert.Equal(0, replacementProvider.LoadCount); + Assert.Equal(0, replacementClient.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task MissingExternalContinuationNeverCreatesReplacementConversationAsync() + { + RecordingHistoryProvider firstProvider = new(); + ChatClientAgent firstAgent = CreateAgentWithProvider(new RecordingChatClient(), firstProvider); + DurableAgentState persisted = await RunEntityAsync( + firstAgent, + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + DurableAgentState missingContinuation = CopyState(persisted, session: null); + + RecordingHistoryProvider replacementProvider = new(); + RecordingChatClient replacementClient = new(); + EntityHarness harness = CreateHarness( + CreateAgentWithProvider(replacementClient, replacementProvider), + missingContinuation, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("second") { CorrelationId = "second" })); + + Assert.Equal(0, replacementProvider.LoadCount); + Assert.Equal(0, replacementClient.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task BoundExternalProviderRejectsSessionWithMissingDeclaredStateBeforeCallbacksAsync() + { + DurableAgentState persisted = await CreateBoundExternalStateAsync(); + RecordingHistoryProvider replacementProvider = new(); + RecordingChatClient replacementClient = new(); + ChatClientAgent replacementAgent = CreateAgentWithProvider( + replacementClient, + replacementProvider); + JsonElement emptySession = await replacementAgent.SerializeSessionAsync( + await replacementAgent.CreateSessionAsync()); + EntityHarness harness = CreateHarness( + replacementAgent, + CopyState(persisted, emptySession), + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("second") { CorrelationId = "second" })); + + Assert.Equal(0, replacementProvider.LoadCount); + Assert.Equal(0, replacementClient.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ExternalProviderCannotSealWithoutItsDeclaredContinuationStateAsync() + { + RecordingHistoryProvider provider = new() { SkipContinuationWrite = true }; + RecordingChatClient client = new(); + EntityHarness harness = CreateHarness( + CreateAgentWithProvider(client, provider), + new DurableAgentState(), + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("first") { CorrelationId = "first" })); + + Assert.Equal(1, provider.LoadCount); + Assert.Equal(1, provider.StoreCount); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ExternalProviderWithoutStateKeysFailsBeforeCallbacksAsync() + { + EmptyStateKeysHistoryProvider provider = new(); + RecordingChatClient client = new(); + EntityHarness harness = CreateHarness( + new ChatClientAgent( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = provider, + }), + new DurableAgentState(), + options => options.SetHistoryProviderKey("agent", "empty-provider.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("first") { CorrelationId = "first" })); + + Assert.Equal(0, provider.LoadCount); + Assert.Equal(0, provider.StoreCount); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ExternalProviderRequiresEveryDeclaredContinuationKeyAsync() + { + MultiKeyHistoryProvider provider = new(writeSecondKey: false); + RecordingChatClient client = new(); + EntityHarness harness = CreateHarness( + new ChatClientAgent( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = provider, + }), + new DurableAgentState(), + options => options.SetHistoryProviderKey("agent", "multi-key-history.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("first") { CorrelationId = "first" })); + + Assert.Equal(1, provider.StoreCount); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ExternalProviderSealsWhenEveryDeclaredContinuationKeyExistsAsync() + { + MultiKeyHistoryProvider provider = new(writeSecondKey: true); + RecordingChatClient client = new(); + DurableAgentState persisted = await RunEntityAsync( + new ChatClientAgent( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = provider, + }), + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }, + options => options.SetHistoryProviderKey("agent", "multi-key-history.v1")); + + Assert.Equal(1, provider.StoreCount); + Assert.Equal("multi-key-history.v1", GetBinding(persisted)?.ProviderKey); + } + + [Fact] + public async Task AmbiguousLegacyExternalOwnershipFailsBeforeCallbacksAsync() + { + RecordingHistoryProvider provider = new(); + RecordingChatClient client = new(); + ChatClientAgent agent = CreateAgentWithProvider(client, provider); + DurableAgentState legacyState = CreateStateWithExchange("old", "old request", "old response"); + EntityHarness harness = CreateHarness( + agent, + legacyState, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + DurableAgentHistoryBindingMismatchException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Contains("Legacy durable state", exception.Message, StringComparison.Ordinal); + Assert.Equal(0, provider.LoadCount); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task UnboundSchema2EntityTranscriptCannotSwitchToExternalProviderAsync() + { + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + ConversationHistory = + [ + DurableAgentStateRequest.FromRunRequestV2( + new RunRequest("old request") { CorrelationId = "old" }), + DurableAgentStateResponse.FromResponseV2( + "old", + new AgentResponse( + new ChatMessage(ChatRole.Assistant, "old response"))), + ], + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + }, + }; + RecordingHistoryProvider provider = new(); + RecordingChatClient client = new(); + EntityHarness harness = CreateHarness( + CreateAgentWithProvider(client, provider), + state, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, provider.LoadCount); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task UnsealedEntitySessionWithInMemoryOnlyHistoryFailsBeforeModelAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + AgentSession session = await agent.CreateSessionAsync(); + session.StateBag.SetValue( + nameof(InMemoryChatHistoryProvider), + new InMemoryChatHistoryProvider.State + { + Messages = [new ChatMessage(ChatRole.User, "session-only history")], + }); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + Session = await agent.SerializeSessionAsync(session), + }, + }; + EntityHarness harness = CreateHarness(agent, state); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ProvisionalExternalKeyRemainsUsableAfterCSharpSealWithoutExplicitOptionAsync() + { + RecordingHistoryProvider provider = new(); + RecordingChatClient firstClient = new(); + ChatClientAgent firstAgent = CreateAgentWithProvider(firstClient, provider); + AgentSession session = await firstAgent.CreateSessionAsync(); + session.StateBag.SetValue( + "external-history", + new ExternalHistoryState { Count = 4 }); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + HistoryBinding = DurableAgentHistoryBinding.ToJson( + new DurableAgentStateHistoryBinding + { + OwnerKind = DurableAgentStateHistoryBinding.HistoryProviderOwner, + ProviderKey = "external-history.v1", + }), + Session = await firstAgent.SerializeSessionAsync(session), + }, + }; + + DurableAgentState firstWrite = await RunEntityAsync( + firstAgent, + state, + new RunRequest("first") { CorrelationId = "first" }); + Assert.True( + firstWrite.Data.HistoryBinding + .GetProperty("csharpFixedOwner") + .GetBoolean()); + + RecordingHistoryProvider secondProvider = new(); + RecordingChatClient secondClient = new(); + DurableAgentState secondWrite = await RunEntityAsync( + CreateAgentWithProvider(secondClient, secondProvider), + DeserializeState(SerializeState(firstWrite)), + new RunRequest("second") { CorrelationId = "second" }); + + Assert.Equal(1, secondProvider.LoadCount); + Assert.Equal(1, secondClient.InvocationCount); + Assert.Equal("external-history.v1", GetBinding(secondWrite)?.ProviderKey); + } + + [Fact] + public async Task OpaqueSharedBindingRejectsCurrentRequestOnlyBeforeModelAsync() + { + RecordingAgent agent = new("agent"); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + HistoryBinding = JsonSerializer.SerializeToElement(new + { + runtime = "python", + perRunOwnership = true, + }), + }, + }; + EntityHarness harness = CreateHarness( + agent, + state, + options => + { + options.SetHistoryReplayMode("agent", DurableAgentHistoryReplayMode.CurrentRequestOnly); + options.SetHistoryProviderKey("agent", "opaque-agent-session.v1"); + }); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Empty(agent.LastMessages); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task OpaqueSharedBindingRejectsPerCallServiceBeforeModelAsync() + { + RecordingChatClient client = new(); +#pragma warning disable MAAI001 + ChatClientAgent agent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + RequirePerServiceCallChatHistoryPersistence = true, + }); +#pragma warning restore MAAI001 + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + HistoryBinding = JsonSerializer.SerializeToElement(new + { + runtime = "python", + perRunOwnership = true, + }), + }, + }; + EntityHarness harness = CreateHarness( + agent, + state, + options => + { + options.SetServiceManagedPerServiceCallHistory("agent"); + options.SetHistoryProviderKey("agent", "model-service.v1"); + }); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task OpaqueSharedBindingRejectsPostResponseServiceTransitionBeforeCommitAsync() + { + RecordingChatClient client = new() { ResponseConversationId = "remote-conversation" }; + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + HistoryBinding = JsonSerializer.SerializeToElement(new + { + runtime = "python", + perRunOwnership = true, + }), + }, + }; + EntityHarness harness = CreateHarness( + agent, + state, + options => options.SetHistoryProviderKey("agent", "model-service.v1")); + + DurableAgentHistoryBindingMismatchException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Contains("remote service may already have observed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task LegacyExternalProviderAdoptsOnlyDeclaredContinuationEvidenceAsync() + { + RecordingHistoryProvider provider = new(); + RecordingChatClient client = new(); + ChatClientAgent agent = CreateAgentWithProvider(client, provider); + DurableAgentState legacyState = CreateStateWithExchange("old", "old request", "old response"); + AgentSession session = await agent.CreateSessionAsync(); + session.StateBag.SetValue( + "external-history", + new ExternalHistoryState { Count = 7 }); + legacyState.Data.Session = await agent.SerializeSessionAsync(session); + + DurableAgentState persisted = await RunEntityAsync( + agent, + legacyState, + new RunRequest("new") { CorrelationId = "new" }, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + Assert.Equal(1, provider.LoadCount); + Assert.Equal(1, provider.StoreCount); + Assert.Equal(2, persisted.Data.ConversationHistory.Count); + Assert.Equal(DurableAgentStateHistoryBinding.HistoryProviderOwner, GetBinding(persisted)?.OwnerKind); + Assert.Equal("external-history.v1", GetBinding(persisted)?.ProviderKey); + } + + [Fact] + public async Task UnexpectedServiceTransitionRejectsBeforeCommitButCannotUndoRemoteCallAsync() + { + ChatClientAgent firstAgent = new(new RecordingChatClient(), name: "agent"); + DurableAgentState persisted = await RunEntityAsync( + firstAgent, + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }); + + RecordingChatClient transitioningClient = new() { ResponseConversationId = "remote-conversation" }; + ChatClientAgent transitioningAgent = new(transitioningClient, name: "agent"); + EntityHarness harness = CreateHarness( + transitioningAgent, + DeserializeState(SerializeState(persisted)), + options => options.SetHistoryProviderKey("agent", "model-service.v1")); + + DurableAgentHistoryBindingMismatchException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("second") { CorrelationId = "second" })); + + Assert.Contains("remote service may already have observed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, transitioningClient.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task FirstServiceTransitionWithoutLogicalKeyWarnsAboutRemoteSideEffectsAsync() + { + RecordingChatClient client = new() { ResponseConversationId = "remote-conversation" }; + ChatClientAgent agent = new(client, name: "agent"); + EntityHarness harness = CreateHarness(agent, new DurableAgentState()); + + DurableAgentHistoryBindingMismatchException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("first") { CorrelationId = "first" })); + + Assert.Contains("remote service may already have observed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task CustomProviderServiceConflictWarnsAboutRemoteSideEffectsAsync() + { + RecordingHistoryProvider provider = new(); + RecordingChatClient client = new() { ResponseConversationId = "remote-conversation" }; + EntityHarness harness = CreateHarness( + CreateAgentWithProvider(client, provider), + new DurableAgentState(), + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + DurableAgentHistoryBindingMismatchException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("first") { CorrelationId = "first" })); + + Assert.IsType(exception.InnerException); + Assert.Contains("remote service may already have observed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, provider.LoadCount); + Assert.Equal(0, provider.StoreCount); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task MissingServiceConversationIdWarnsAboutRemoteSideEffectsAsync() + { + RecordingChatClient client = new() { SuppressConversationId = true }; + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState initialState = new(); + initialState.Data.Session = await agent.SerializeSessionAsync( + await agent.CreateSessionAsync("service-conversation")); + EntityHarness harness = CreateHarness( + agent, + initialState, + options => options.SetHistoryProviderKey("agent", "model-service.v1")); + + DurableAgentHistoryBindingMismatchException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("next") { CorrelationId = "next" })); + + Assert.IsType(exception.InnerException); + Assert.Contains("remote service may already have observed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task AmbiguousLegacyServiceTransitionWarnsAboutRemoteSideEffectsAsync() + { + DurableAgentState legacyState = CreateStateWithExchange("old", "old request", "old response"); + RecordingChatClient client = new() { ResponseConversationId = "remote-conversation" }; + ChatClientAgent agent = new(client, name: "agent"); + EntityHarness harness = CreateHarness( + agent, + legacyState, + options => options.SetHistoryProviderKey("agent", "model-service.v1")); + + DurableAgentHistoryBindingMismatchException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + + Assert.Contains("remote service may already have observed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task MatchingHistoryBindingPreservesForwardCompatibleFieldsAsync() + { + ChatClientAgent agent = new(new RecordingChatClient(), name: "agent"); + DurableAgentState firstWrite = await RunEntityAsync( + agent, + new DurableAgentState(), + new RunRequest("first") { CorrelationId = "first" }); + DurableAgentStateHistoryBinding firstBinding = GetBinding(firstWrite)!; + Dictionary bindingProperties = + firstBinding.UnknownProperties? + .ToDictionary(pair => pair.Key, pair => pair.Value) ?? []; + bindingProperties["futureBindingField"] = + JsonSerializer.SerializeToElement(new { preserve = true }); + DurableAgentState withFutureBindingField = CopyState( + firstWrite, + session: firstWrite.Data.Session, + historyBinding: new DurableAgentStateHistoryBinding + { + OwnerKind = firstBinding.OwnerKind, + ProviderKey = firstBinding.ProviderKey, + UnknownProperties = bindingProperties, + }); + + DurableAgentState secondWrite = await RunEntityAsync( + agent, + withFutureBindingField, + new RunRequest("second") { CorrelationId = "second" }); + + Assert.True( + GetBinding(secondWrite)?.UnknownProperties? + .ContainsKey("futureBindingField") is true); + } + + [Fact] + public async Task FailureAfterConversationFinalizationDoesNotMutateHydratedStateAsync() + { + FailingSerializationAgent agent = new("agent"); + DurableAgentState initialState = CreateStateWithExchange("old", "old request", "old response"); + EntityHarness harness = CreateHarness(agent, initialState); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" })); + + Assert.True(agent.ExecutionCompleted); + Assert.False(harness.StateWasPersisted); + Assert.Equal(2, initialState.Data.ConversationHistory.Count); + Assert.DoesNotContain( + initialState.Data.ConversationHistory, + entry => entry.CorrelationId == "new"); + } + + [Fact] + public async Task ProviderLoadFailureDoesNotInvokeModelOrCommitWorkingStateAsync() + { + InvalidOperationException expected = new("provider load failed"); + RecordingHistoryProvider provider = new() { LoadException = expected }; + RecordingChatClient client = new(); + ChatClientAgent agent = CreateAgentWithProvider(client, provider); + DurableAgentState initialState = await CreateBoundExternalStateAsync(); + string originalState = SerializeState(initialState); + EntityHarness harness = CreateHarness( + agent, + initialState, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + InvalidOperationException actual = await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" })); + + Assert.Same(expected, actual); + Assert.Equal(1, provider.LoadCount); + Assert.Equal(0, provider.StoreCount); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(originalState, SerializeState(initialState)); + Assert.Contains(harness.Logs, entry => ReferenceEquals(expected, entry.Exception)); + } + + [Fact] + public async Task ProviderStoreFailureDoesNotCommitWorkingStateAsync() + { + InvalidOperationException expected = new("provider store failed"); + RecordingHistoryProvider provider = new() { StoreException = expected }; + RecordingChatClient client = new(); + ChatClientAgent agent = CreateAgentWithProvider(client, provider); + DurableAgentState initialState = await CreateBoundExternalStateAsync(); + string originalState = SerializeState(initialState); + EntityHarness harness = CreateHarness( + agent, + initialState, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + + InvalidOperationException actual = await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" })); + + Assert.Same(expected, actual); + Assert.Equal(1, provider.LoadCount); + Assert.Equal(1, provider.StoreCount); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(originalState, SerializeState(initialState)); + Assert.Contains(harness.Logs, entry => ReferenceEquals(expected, entry.Exception)); + } + + [Fact] + public async Task ProviderLoadCancellationPropagatesWithoutCommitOrWrappingAsync() + { + using TestHostApplicationLifetime lifetime = new(); + RecordingHistoryProvider provider = new() + { + WaitForLoadCancellation = true, + }; + RecordingChatClient client = new(); + ChatClientAgent agent = CreateAgentWithProvider(client, provider); + DurableAgentState initialState = await CreateBoundExternalStateAsync(); + string originalState = SerializeState(initialState); + EntityHarness harness = CreateHarness( + agent, + initialState, + options => options.SetHistoryProviderKey("agent", "external-history.v1"), + applicationLifetime: lifetime); + + Task runTask = harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" }); + try + { + await WaitForProviderStageAsync( + provider.LoadStarted.Task, + runTask, + "history provider load callback"); + lifetime.StopApplication(); + OperationCanceledException actual = + await Assert.ThrowsAnyAsync( + () => runTask.WaitAsync(s_testTimeout)); + + Assert.Same(provider.LoadCancellationException, actual); + Assert.Equal(lifetime.ApplicationStopping, provider.LoadCancellationToken); + Assert.Equal(1, provider.LoadCount); + Assert.Equal(0, provider.StoreCount); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(originalState, SerializeState(initialState)); + } + finally + { + lifetime.StopApplication(); + await JoinRunTaskAsync(runTask, "history provider load cancellation"); + } + } + + [Fact] + public async Task ProviderStoreCancellationPropagatesWithoutPartialCommitOrWrappingAsync() + { + using TestHostApplicationLifetime lifetime = new(); + RecordingHistoryProvider provider = new() + { + WaitForStoreCancellation = true, + }; + RecordingChatClient client = new(); + ChatClientAgent agent = CreateAgentWithProvider(client, provider); + DurableAgentState initialState = await CreateBoundExternalStateAsync(); + string originalState = SerializeState(initialState); + EntityHarness harness = CreateHarness( + agent, + initialState, + options => options.SetHistoryProviderKey("agent", "external-history.v1"), + applicationLifetime: lifetime); + + Task runTask = harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" }); + try + { + await WaitForProviderStageAsync( + provider.StoreStarted.Task, + runTask, + "history provider store callback"); + lifetime.StopApplication(); + OperationCanceledException actual = + await Assert.ThrowsAnyAsync( + () => runTask.WaitAsync(s_testTimeout)); + + Assert.Same(provider.StoreCancellationException, actual); + Assert.Equal(lifetime.ApplicationStopping, client.LastCancellationToken); + Assert.Equal(lifetime.ApplicationStopping, provider.LoadCancellationToken); + Assert.Equal(lifetime.ApplicationStopping, provider.StoreCancellationToken); + Assert.Equal(1, provider.LoadCount); + Assert.Equal(1, provider.StoreCount); + Assert.Equal(1, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(originalState, SerializeState(initialState)); + } + finally + { + lifetime.StopApplication(); + await JoinRunTaskAsync(runTask, "history provider store cancellation"); + } + } + + private static async Task RunEntityAsync( + AIAgent agent, + DurableAgentState state, + RunRequest request, + Action? configure = null) + { + EntityHarness harness = CreateHarness(agent, state, configure); + await harness.RunAsync(request); + return Assert.IsType(harness.PersistedState); + } + + private static EntityHarness CreateHarness( + AIAgent agent, + DurableAgentState state, + Action? configure = null, + bool registerWithFactory = false, + Action? onFactoryInvoked = null, + IHostApplicationLifetime? applicationLifetime = null, + bool enableMailboxWrites = true) + { + AgentSessionId sessionId = new(agent.Name!, "session"); + DurableAgentsOptions options = new() + { + DefaultTimeToLive = null, + EnableMailboxWrites = enableMailboxWrites, + AuthorizeLegacyMigration = enableMailboxWrites ? static _ => true : null, + }; + if (registerWithFactory) + { + options.AddAIAgentFactory( + agent.Name!, + _ => + { + onFactoryInvoked?.Invoke(); + return agent; + }); + } + else + { + options.AddAIAgent(agent); + } + + configure?.Invoke(options); + + ListLoggerProvider loggerProvider = new(); + Dictionary services = new() + { + [typeof(DurableTaskClient)] = new Mock("test").Object, + [typeof(ILoggerFactory)] = new ListLoggerFactory(loggerProvider), + [typeof(DurableAgentsOptions)] = options, + [typeof(IReadOnlyDictionary>)] = options.GetAgentFactories(), + [typeof(IHostApplicationLifetime)] = applicationLifetime ?? + Mock.Of( + lifetime => lifetime.ApplicationStopping == CancellationToken.None), + }; + IServiceProvider serviceProvider = new DictionaryServiceProvider(services); + + Mock context = new(); + context.SetupGet(value => value.Id).Returns(sessionId); + Mock entityState = new(); + entityState.Setup(value => value.GetState(typeof(DurableAgentState))).Returns(state); + object? persistedState = null; + entityState.Setup(value => value.SetState(It.IsAny())) + .Callback(value => persistedState = value); + + Mock operation = new(); + operation.SetupGet(value => value.Name).Returns(nameof(AgentEntity.Run)); + operation.SetupGet(value => value.Context).Returns(context.Object); + operation.SetupGet(value => value.State).Returns(entityState.Object); + operation.SetupGet(value => value.HasInput).Returns(true); + + AgentEntity entity = new(serviceProvider); + return new EntityHarness( + entity, + operation, + loggerProvider, + () => persistedState); + } + + private static ChatClientAgent CreateAgentWithProvider( + RecordingChatClient client, + RecordingHistoryProvider provider) => + new( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = provider, + }); + + private static async Task CreateBoundExternalStateAsync() + { + ChatClientAgent agent = CreateAgentWithProvider( + new RecordingChatClient(), + new RecordingHistoryProvider()); + return await RunEntityAsync( + agent, + new DurableAgentState(), + new RunRequest("seed") { CorrelationId = "seed" }, + options => options.SetHistoryProviderKey("agent", "external-history.v1")); + } + + private static string SerializeState(DurableAgentState state) => + JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + private static DurableAgentState DeserializeState(string json) => + Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + + private static DurableAgentStateHistoryBinding? GetBinding( + DurableAgentState state) => + DurableAgentHistoryBinding.Parse(state.Data.HistoryBinding); + + private static DurableAgentState CopyState( + DurableAgentState state, + JsonElement? session, + DurableAgentStateHistoryBinding? historyBinding = null) + { + return new DurableAgentState + { + SchemaVersion = state.SchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = state.Data.ConversationHistory, + TerminalResults = state.Data.TerminalResults, + CompletionReceipts = state.Data.CompletionReceipts, + HistoryBinding = historyBinding is null + ? state.Data.HistoryBinding + : DurableAgentHistoryBinding.ToJson(historyBinding), + Session = session, + IngestedPositions = state.Data.IngestedPositions, + Truncation = state.Data.Truncation, + ExpirationTimeUtc = state.Data.ExpirationTimeUtc, + ExtensionData = state.Data.ExtensionData, + UnknownProperties = state.Data.UnknownProperties, + }, + ExtensionData = state.ExtensionData, + UnknownProperties = state.UnknownProperties, + }; + } + + private static async Task WaitForProviderStageAsync( + Task stageTask, + Task runTask, + string stageDescription) + { + Task completedTask; + try + { + completedTask = await Task.WhenAny(stageTask, runTask).WaitAsync(s_stageTimeout); + } + catch (TimeoutException exception) + { + throw new Xunit.Sdk.XunitException( + $"Timed out after {s_stageTimeout} waiting to reach the {stageDescription}.", + exception); + } + + if (ReferenceEquals(completedTask, runTask)) + { + try + { + await runTask; + } + catch (Exception exception) + { + throw new Xunit.Sdk.XunitException( + $"The entity run failed before reaching the {stageDescription}.", + exception); + } + + throw new Xunit.Sdk.XunitException( + $"The entity run completed before reaching the {stageDescription}."); + } + + await stageTask; + } + + private static async Task JoinRunTaskAsync(Task runTask, string scenarioDescription) + { + try + { + await runTask.WaitAsync(s_testTimeout); + } + catch (OperationCanceledException) when (runTask.IsCanceled) + { + // The test body asserts the propagated cancellation; cleanup only joins the same task. + } + catch (TimeoutException exception) + { + throw new Xunit.Sdk.XunitException( + $"Timed out after {s_testTimeout} joining the entity run during cleanup for {scenarioDescription}; the test may have leaked a running task.", + exception); + } + catch (Exception exception) + { + throw new Xunit.Sdk.XunitException( + $"The entity run faulted unexpectedly during cleanup for {scenarioDescription}.", + exception); + } + } + + private static DurableAgentState CreateStateWithExchange( + string correlationId, + string request, + string response) + { + DurableAgentState state = new(); + AddExchange(state, correlationId, request, response, DateTimeOffset.UtcNow.AddMinutes(-5)); + return state; + } + + private static void AddExchange( + DurableAgentState state, + string correlationId, + string request, + string response, + DateTimeOffset createdAt) + { + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.User, request) { CreatedAt = createdAt }), + ], + }); + state.Data.ConversationHistory.Add( + new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, response) { CreatedAt = createdAt }), + ], + }); + } + + private sealed class EntityHarness( + AgentEntity entity, + Mock operation, + ListLoggerProvider loggerProvider, + Func persistedState) + { + public object? PersistedState => persistedState(); + + public bool StateWasPersisted => this.PersistedState is not null; + + public IReadOnlyList Logs => loggerProvider.Records; + + public async Task RunAsync(RunRequest request) + { + operation.Setup(value => value.GetInput(typeof(RunRequest))).Returns(request); + object? result = await ((ITaskEntity)entity).RunAsync(operation.Object); + return Assert.IsType(result); + } + } + + private sealed class TestDelegatingAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent); + + private sealed class CountingSessionAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) + { + public int DeserializeCount { get; private set; } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + this.DeserializeCount++; + return base.DeserializeSessionCoreAsync( + serializedState, + jsonSerializerOptions, + cancellationToken); + } + } + + private sealed class RecordingAgent(string name) : AIAgent + { + public override string? Name => name; + + public List LastMessages { get; private set; } = []; + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => new(new RecordingSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { stateBag = session.StateBag.Serialize() })); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(new RecordingSession()); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.LastMessages = messages.ToList(); + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, "response"); + } + + private sealed class RecordingSession : AgentSession; + } + + private sealed class FailingSerializationAgent(string name) : AIAgent + { + public override string? Name => name; + + public bool ExecutionCompleted { get; private set; } + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => new(new FailingSerializationSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("session serialization failed"); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(new FailingSerializationSession()); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.ExecutionCompleted = true; + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, "response"); + } + + private sealed class FailingSerializationSession : AgentSession; + } + + private sealed class RecordingHistoryProvider : ChatHistoryProvider + { + public override IReadOnlyList StateKeys => ["external-history"]; + + public Exception? LoadException { get; init; } + + public Exception? StoreException { get; init; } + + public bool WaitForLoadCancellation { get; init; } + + public bool WaitForStoreCancellation { get; init; } + + public bool SkipContinuationWrite { get; init; } + + public TaskCompletionSource LoadStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource StoreStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int LoadCount { get; private set; } + + public int StoreCount { get; private set; } + + public CancellationToken LoadCancellationToken { get; private set; } + + public CancellationToken StoreCancellationToken { get; private set; } + + public OperationCanceledException? LoadCancellationException { get; private set; } + + public OperationCanceledException? StoreCancellationException { get; private set; } + + protected override async ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + this.LoadCount++; + this.LoadCancellationToken = cancellationToken; + if (this.WaitForLoadCancellation) + { + this.LoadStarted.TrySetResult(true); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken) + .WaitAsync(s_stageTimeout, CancellationToken.None); + } + catch (OperationCanceledException exception) + { + this.LoadCancellationException = exception; + throw; + } + catch (TimeoutException exception) + { + throw new Xunit.Sdk.XunitException( + $"Timed out after {s_stageTimeout} waiting for ApplicationStopping during provider load.", + exception); + } + } + + if (this.LoadException is not null) + { + throw this.LoadException; + } + + return []; + } + + protected override async ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + { + this.StoreCount++; + this.StoreCancellationToken = cancellationToken; + if (!this.SkipContinuationWrite) + { + context.Session!.StateBag.SetValue( + "external-history", + new ExternalHistoryState { Count = this.StoreCount }); + } + if (this.WaitForStoreCancellation) + { + this.StoreStarted.TrySetResult(true); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken) + .WaitAsync(s_stageTimeout, CancellationToken.None); + } + catch (OperationCanceledException exception) + { + this.StoreCancellationException = exception; + throw; + } + catch (TimeoutException exception) + { + throw new Xunit.Sdk.XunitException( + $"Timed out after {s_stageTimeout} waiting for ApplicationStopping during provider store.", + exception); + } + } + + if (this.StoreException is not null) + { + throw this.StoreException; + } + } + } + + private sealed class ExternalHistoryState + { + public int Count { get; set; } + } + + private sealed class MultiKeyHistoryProvider(bool writeSecondKey) : ChatHistoryProvider + { + public override IReadOnlyList StateKeys => ["external-primary", "external-index"]; + + public int StoreCount { get; private set; } + + protected override ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) => + new([]); + + protected override ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + { + this.StoreCount++; + context.Session!.StateBag.SetValue("external-primary", "primary"); + if (writeSecondKey) + { + context.Session.StateBag.SetValue("external-index", "index"); + } + + return default; + } + } + + private sealed class EmptyStateKeysHistoryProvider : ChatHistoryProvider + { + public override IReadOnlyList StateKeys => []; + + public int LoadCount { get; private set; } + + public int StoreCount { get; private set; } + + protected override ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + this.LoadCount++; + return new([]); + } + + protected override ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + { + this.StoreCount++; + return default; + } + } + + private sealed class RecordingChatClient : IChatClient + { + public Exception? Exception { get; init; } + + public string? ResponseConversationId { get; init; } + + public bool SuppressConversationId { get; init; } + + public int InvocationCount { get; private set; } + + public CancellationToken LastCancellationToken { get; private set; } + + public List LastMessages { get; private set; } = []; + + public void Dispose() + { + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.InvocationCount++; + this.LastCancellationToken = cancellationToken; + this.LastMessages = messages.ToList(); + if (this.Exception is not null) + { + throw this.Exception; + } + + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "response") + { + ConversationId = this.SuppressConversationId + ? null + : this.ResponseConversationId ?? options?.ConversationId, + }; + } + } + + private sealed class TestHostApplicationLifetime : IHostApplicationLifetime, IDisposable + { + private readonly CancellationTokenSource _applicationStopping = new(); + + public CancellationToken ApplicationStarted => CancellationToken.None; + + public CancellationToken ApplicationStopping => this._applicationStopping.Token; + + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() => this._applicationStopping.Cancel(); + + public void Dispose() => this._applicationStopping.Dispose(); + } + + private sealed class ListLoggerProvider : ILoggerProvider + { + public List Records { get; } = []; + + public ILogger CreateLogger(string categoryName) => new ListLogger(this.Records); + + public void Dispose() + { + } + } + + private sealed class ListLoggerFactory : ILoggerFactory + { + private readonly ListLoggerProvider _provider; + + public ListLoggerFactory(ListLoggerProvider provider) + { + this._provider = provider; + } + + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) => this._provider.CreateLogger(categoryName); + + public void Dispose() => this._provider.Dispose(); + } + + private sealed class DictionaryServiceProvider(IReadOnlyDictionary services) : IServiceProvider + { + public object? GetService(Type serviceType) => + services.TryGetValue(serviceType, out object? service) ? service : null; + } + + private sealed class ListLogger(List records) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + records.Add(new LogRecord(logLevel, eventId, exception, formatter(state, exception))); + } + } + + private sealed record LogRecord( + LogLevel Level, + EventId EventId, + Exception? Exception, + string Message); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentHistoryOwnershipTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentHistoryOwnershipTests.cs new file mode 100644 index 0000000..eaf81ec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentHistoryOwnershipTests.cs @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableAgentHistoryOwnershipTests +{ + [Fact] + public void FindChatClientAgentTraversesNestedDelegatingWrappers() + { + ChatClientAgent chatAgent = new(new StubChatClient(), name: "agent"); + AIAgent wrappedAgent = new TestDelegatingAgent( + new TestDelegatingAgent(chatAgent)); + + ChatClientAgent? discovered = + DurableAgentHistoryOwnershipResolver.FindChatClientAgent(wrappedAgent); + + Assert.Same(chatAgent, discovered); + } + + [Fact] + public async Task DefaultProviderIsEntityOwnedThroughWrapperAsync() + { + ChatClientAgent chatAgent = new(new StubChatClient(), name: "agent"); + AIAgent wrappedAgent = new TestDelegatingAgent(chatAgent); + AgentSession session = await wrappedAgent.CreateSessionAsync(); + + (DurableAgentHistoryOwnership ownership, ChatClientAgent? resolvedAgent) = + DurableAgentHistoryOwnershipResolver.Resolve(wrappedAgent, session); + + Assert.Equal(DurableAgentHistoryOwnership.Entity, ownership); + Assert.Same(chatAgent, resolvedAgent); + } + + [Fact] + public void ExplicitInMemoryProviderIsRejectedBeforeExecution() + { + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = new InMemoryChatHistoryProvider( + new InMemoryChatHistoryProviderOptions + { + ProvideOutputMessageFilter = messages => + messages.Where(message => message.Role != ChatRole.System), + }), + }); + + DurableAgentHistoryOwnershipNotSupportedException exception = + Assert.Throws( + () => DurableAgentHistoryOwnershipResolver.ValidateStaticConfiguration(chatAgent)); + + Assert.Contains("Explicitly configured", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CustomProviderRemainsAuthoritativeAsync() + { + CustomHistoryProvider provider = new(); + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = provider, + }); + AgentSession session = await chatAgent.CreateSessionAsync(); + + (DurableAgentHistoryOwnership ownership, _) = + DurableAgentHistoryOwnershipResolver.Resolve(chatAgent, session); + + Assert.Equal(DurableAgentHistoryOwnership.ExternalProvider, ownership); + } + + [Fact] + public async Task ServiceConversationRemainsAuthoritativeAsync() + { + ChatClientAgent chatAgent = new(new StubChatClient(), name: "agent"); + AgentSession session = await chatAgent.CreateSessionAsync("service-id"); + + (DurableAgentHistoryOwnership ownership, _) = + DurableAgentHistoryOwnershipResolver.Resolve(chatAgent, session); + + Assert.Equal(DurableAgentHistoryOwnership.Service, ownership); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task EmptyOrWhitespaceConversationIdDoesNotEstablishServiceOwnershipAsync( + string conversationId) + { + ChatClientAgent chatAgent = new(new StubChatClient(), name: "agent"); + AgentSession session = await chatAgent.CreateSessionAsync(conversationId); + + (DurableAgentHistoryOwnership ownership, _) = + DurableAgentHistoryOwnershipResolver.Resolve(chatAgent, session); + + Assert.Equal(DurableAgentHistoryOwnership.Entity, ownership); + } + + [Fact] + public async Task FrameworkLocalConversationSentinelDoesNotEstablishServiceOwnershipAsync() + { + ChatClientAgent chatAgent = new(new StubChatClient(), name: "agent"); + AgentSession session = await chatAgent.CreateSessionAsync( + DurableAgentHistoryBinding.FrameworkLocalHistoryConversationId); + + (DurableAgentHistoryOwnership ownership, _) = + DurableAgentHistoryOwnershipResolver.Resolve(chatAgent, session); + + Assert.Equal(DurableAgentHistoryOwnership.Entity, ownership); + } + + [Fact] + public async Task PerServiceCallServiceOwnershipIsExplicitAsync() + { +#pragma warning disable MAAI001 + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + RequirePerServiceCallChatHistoryPersistence = true, + }); +#pragma warning restore MAAI001 + AgentSession session = await chatAgent.CreateSessionAsync(); + + (DurableAgentHistoryOwnership ownership, _) = + DurableAgentHistoryOwnershipResolver.Resolve( + chatAgent, + session, + serviceManagedPerServiceCallHistory: true); + + Assert.Equal(DurableAgentHistoryOwnership.Service, ownership); + } + + [Fact] + public async Task PerServiceCallServiceOwnershipTraversesWrapperAsync() + { +#pragma warning disable MAAI001 + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + RequirePerServiceCallChatHistoryPersistence = true, + }); +#pragma warning restore MAAI001 + AIAgent wrappedAgent = new TestDelegatingAgent(chatAgent); + AgentSession session = await wrappedAgent.CreateSessionAsync(); + + (DurableAgentHistoryOwnership ownership, ChatClientAgent? resolvedAgent) = + DurableAgentHistoryOwnershipResolver.Resolve( + wrappedAgent, + session, + serviceManagedPerServiceCallHistory: true); + + Assert.Equal(DurableAgentHistoryOwnership.Service, ownership); + Assert.Same(chatAgent, resolvedAgent); + } + + [Fact] + public async Task PerServiceCallOwnershipWithoutExplicitConfigurationFailsAsync() + { +#pragma warning disable MAAI001 + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + RequirePerServiceCallChatHistoryPersistence = true, + }); +#pragma warning restore MAAI001 + AgentSession session = await chatAgent.CreateSessionAsync(); + + Assert.Throws( + () => DurableAgentHistoryOwnershipResolver.Resolve(chatAgent, session)); + } + + [Fact] + public async Task ServiceManagedPerCallDeclarationIsIgnoredWhenPerCallPersistenceIsDisabledAsync() + { + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = new CustomHistoryProvider(), + }); + AgentSession session = await chatAgent.CreateSessionAsync(); + + (DurableAgentHistoryOwnership ownership, _) = + DurableAgentHistoryOwnershipResolver.Resolve( + chatAgent, + session, + serviceManagedPerServiceCallHistory: true); + + Assert.Equal(DurableAgentHistoryOwnership.ExternalProvider, ownership); + } + + [Fact] + public void ServiceManagedPerCallDeclarationMatchesAgentNamesCaseInsensitively() + { + DurableAgentsOptions options = new(); + + DurableAgentsOptions returnedOptions = + options.SetServiceManagedPerServiceCallHistory("Agent"); + + Assert.Same(options, returnedOptions); + Assert.True(options.IsServiceManagedPerServiceCallHistory("agent")); + Assert.False(options.IsServiceManagedPerServiceCallHistory("other")); + } + + [Fact] + public void HistoryReplayModeDefaultsToEntityPreloadAndMatchesNamesCaseInsensitively() + { + DurableAgentsOptions options = new(); + + Assert.Equal( + DurableAgentHistoryReplayMode.PreloadEntityHistory, + options.GetHistoryReplayMode("agent")); + + DurableAgentsOptions returned = options.SetHistoryReplayMode( + "Agent", + DurableAgentHistoryReplayMode.CurrentRequestOnly); + + Assert.Same(options, returned); + Assert.Equal( + DurableAgentHistoryReplayMode.CurrentRequestOnly, + options.GetHistoryReplayMode("agent")); + } + + [Fact] + public void InvalidHistoryReplayModeFailsDuringOptionsConfiguration() + { + DurableAgentsOptions options = new(); + + Assert.Throws( + () => options.SetHistoryReplayMode( + "agent", + (DurableAgentHistoryReplayMode)int.MaxValue)); + } + + [Fact] + public void LogicalHistoryProviderKeyIsStableCaseInsensitiveRegistrationMetadata() + { + DurableAgentsOptions options = new(); + + DurableAgentsOptions returned = + options.SetHistoryProviderKey("Agent", "contoso.history.v1"); + + Assert.Same(options, returned); + Assert.Equal("contoso.history.v1", options.GetHistoryProviderKey("agent")); + Assert.Throws( + () => options.SetHistoryProviderKey("AGENT", "contoso.history.v2")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid\u0001key")] + public void InvalidLogicalHistoryProviderKeyFailsDuringConfiguration(string providerKey) + { + DurableAgentsOptions options = new(); + + Assert.ThrowsAny( + () => options.SetHistoryProviderKey("agent", providerKey)); + } + + [Fact] + public void DirectAgentRegistrationRejectsStaticCompactionConfiguration() + { + ChatClientAgent agent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + AIContextProviders = + [ + new CompactionProvider( + new SlidingWindowCompactionStrategy(_ => true)), + ], + }); + DurableAgentsOptions options = new(); + + Assert.Throws( + () => options.AddAIAgent(agent)); + } + + [Fact] + public void UnsupportedLocalProviderOwnershipEnumIsNotPublic() + { + Type? ownershipType = typeof(DurableAgentsOptions).Assembly.GetType( + "Microsoft.Agents.AI.DurableTask.DurableAgentPerServiceCallHistoryOwnership"); + + Assert.Null(ownershipType); + } + + [Fact] + public async Task AgentWithoutChatPipelineUsesFallbackAsync() + { + AIAgent agent = new StubAgent(); + AgentSession session = await agent.CreateSessionAsync(); + + (DurableAgentHistoryOwnership ownership, ChatClientAgent? chatClientAgent) = + DurableAgentHistoryOwnershipResolver.Resolve(agent, session); + + Assert.Equal(DurableAgentHistoryOwnership.NoContextPipeline, ownership); + Assert.Null(chatClientAgent); + } + + [Theory] + [InlineData(DurableAgentHistoryReplayMode.PreloadEntityHistory, "Entity")] + [InlineData(DurableAgentHistoryReplayMode.CurrentRequestOnly, "AgentSession")] + public void NoContextPipelineMapsToFixedRuntimeOwner( + DurableAgentHistoryReplayMode replayMode, + string expected) + { + Assert.Equal( + expected, + DurableAgentHistoryOwnershipResolver.GetEffectiveOwnership( + DurableAgentHistoryOwnership.NoContextPipeline, + replayMode).ToString()); + } + + [Fact] + public void HiddenBuilderPipelineCannotBeUniversallyInspectedThroughPublicAgentServices() + { + ChatClientAgent hiddenChatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "hidden", + AIContextProviders = + [ + new CompactionProvider( + new SlidingWindowCompactionStrategy(_ => true)), + ], + }); + AIAgent opaqueAgent = new OpaqueAgent(hiddenChatAgent); + + Assert.Null(DurableAgentHistoryOwnershipResolver.FindChatClientAgent(opaqueAgent)); + DurableAgentHistoryOwnershipResolver.ValidateStaticConfiguration(opaqueAgent); + } + + [Fact] + public async Task StatefulReducerFailsBeforeExecutionAsync() + { + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = new InMemoryChatHistoryProvider( + new InMemoryChatHistoryProviderOptions + { + ChatReducer = new NoOpReducer(), + }), + }); + AgentSession session = await chatAgent.CreateSessionAsync(); + + Assert.Throws( + () => DurableAgentHistoryOwnershipResolver.Resolve(chatAgent, session)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompactionProviderFailsForExternalAndServiceOwnershipAsync(bool serviceOwned) + { + ChatClientAgent chatAgent = new( + new StubChatClient(), + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = new CustomHistoryProvider(), + AIContextProviders = + [ + new CompactionProvider( + new SlidingWindowCompactionStrategy(_ => true)), + ], + }); + AgentSession session = serviceOwned + ? await chatAgent.CreateSessionAsync("service-id") + : await chatAgent.CreateSessionAsync(); + + Assert.Throws( + () => DurableAgentHistoryOwnershipResolver.Resolve(chatAgent, session)); + } + + private sealed class TestDelegatingAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent); + + private sealed class OpaqueAgent(AIAgent innerAgent) : AIAgent + { + public override string? Name => "opaque"; + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + innerAgent.CreateSessionAsync(cancellationToken); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + innerAgent.RunAsync(messages, session, options, cancellationToken); + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + innerAgent.RunStreamingAsync(messages, session, options, cancellationToken); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + innerAgent.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + innerAgent.DeserializeSessionAsync(serializedState, jsonSerializerOptions, cancellationToken); + } + + private sealed class CustomHistoryProvider : ChatHistoryProvider; + + private sealed class NoOpReducer : IChatReducer + { + public Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken) => Task.FromResult(messages); + } + + private sealed class StubAgent : AIAgent + { + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => new(new StubSession()); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new AgentResponse()); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield break; + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => new(new StubSession()); + + private sealed class StubSession : AgentSession; + } + + private sealed class StubChatClient : IChatClient + { + public void Dispose() + { + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "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, "response"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentSessionStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentSessionStateTests.cs new file mode 100644 index 0000000..901a9cf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentSessionStateTests.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableAgentSessionStateTests +{ + [Fact] + public async Task ColdRestorePreservesNonHistoryProviderStateAsync() + { + ChatClientAgent agent = new(new StubChatClient(), name: "agent"); + AgentSession session = await agent.CreateSessionAsync(); + session.StateBag.SetValue("custom", "value"); + session.StateBag.SetValue("custom-compaction-state", "preserved"); + session.StateBag.SetValue( + nameof(InMemoryChatHistoryProvider), + new InMemoryChatHistoryProvider.State + { + Messages = [new ChatMessage(ChatRole.User, "duplicate")], + }); + + var serialized = await DurableAgentSessionState.SerializeAsync( + agent, + session, + [nameof(InMemoryChatHistoryProvider)], + CancellationToken.None); + AgentSession restored = await DurableAgentSessionState.RestoreAsync( + agent, + serialized, + CancellationToken.None); + + Assert.Equal("value", restored.StateBag.GetValue("custom")); + Assert.Equal( + "preserved", + restored.StateBag.GetValue("custom-compaction-state")); + Assert.False( + restored.StateBag.TryGetValue( + nameof(InMemoryChatHistoryProvider), + out _)); + } + + [Fact] + public async Task ExternalProviderStateIsPreservedWhenNothingIsExcludedAsync() + { + ChatClientAgent agent = new(new StubChatClient(), name: "agent"); + AgentSession session = await agent.CreateSessionAsync(); + session.StateBag.SetValue("external-history", new ExternalState { ConversationKey = "abc" }); + + var serialized = await DurableAgentSessionState.SerializeAsync( + agent, + session, + [], + CancellationToken.None); + AgentSession restored = await DurableAgentSessionState.RestoreAsync( + agent, + serialized, + CancellationToken.None); + + ExternalState? state = restored.StateBag.GetValue("external-history"); + Assert.Equal("abc", state?.ConversationKey); + } + + [Fact] + public async Task ServiceConversationIdentityRoundTripsAsync() + { + ChatClientAgent agent = new(new StubChatClient(), name: "agent"); + AgentSession session = await agent.CreateSessionAsync("service-id"); + + var serialized = await DurableAgentSessionState.SerializeAsync( + agent, + session, + [], + CancellationToken.None); + AgentSession restored = await DurableAgentSessionState.RestoreAsync( + agent, + serialized, + CancellationToken.None); + + ChatClientAgentSession typed = Assert.IsType(restored); + Assert.Equal("service-id", typed.ConversationId); + } + + [Fact] + public async Task UsesTheConcreteAgentsSessionSerializationContractAsync() + { + CustomSessionAgent agent = new(); + AgentSession created = await DurableAgentSessionState.RestoreAsync( + agent, + serializedSession: null, + CancellationToken.None); + + JsonElement serialized = await DurableAgentSessionState.SerializeAsync( + agent, + created, + [], + CancellationToken.None); + AgentSession restored = await DurableAgentSessionState.RestoreAsync( + agent, + serialized, + CancellationToken.None); + + Assert.IsType(created); + Assert.IsType(restored); + Assert.Equal("agent-owned-format", serialized.GetProperty("format").GetString()); + Assert.Equal(1, agent.CreateCount); + Assert.Equal(1, agent.SerializeCount); + Assert.Equal(1, agent.DeserializeCount); + } + + [Fact] + public async Task InvalidSerializedContinuationDoesNotFallBackToCreatingANewSessionAsync() + { + CustomSessionAgent agent = new(); + JsonElement invalid = JsonSerializer.SerializeToElement(new { format = "unexpected" }); + + await Assert.ThrowsAnyAsync( + async () => await DurableAgentSessionState.RestoreAsync( + agent, + invalid, + CancellationToken.None)); + + Assert.Equal(0, agent.CreateCount); + Assert.Equal(1, agent.DeserializeCount); + } + + private sealed class ExternalState + { + public string? ConversationKey { get; set; } + } + + private sealed class StubChatClient : IChatClient + { + public void Dispose() + { + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "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, "response"); + } + } + + private sealed class CustomSessionAgent : AIAgent + { + public int CreateCount { get; private set; } + + public int SerializeCount { get; private set; } + + public int DeserializeCount { get; private set; } + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) + { + this.CreateCount++; + return new(new CustomSession()); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + Assert.IsType(session); + this.SerializeCount++; + return new(JsonSerializer.SerializeToElement(new { format = "agent-owned-format" })); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + this.DeserializeCount++; + Assert.Equal("agent-owned-format", serializedState.GetProperty("format").GetString()); + return new(new CustomSession()); + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield break; + } + + public sealed class CustomSession : AgentSession; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableChatHistoryProviderTests.cs new file mode 100644 index 0000000..410b821 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableChatHistoryProviderTests.cs @@ -0,0 +1,478 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableChatHistoryProviderTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task EmptyOrWhitespaceConversationIdStillStagesEntityHistoryAsync( + string conversationId) + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync(conversationId); + DurableAgentState state = new(); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new( + state.Data.ConversationHistory, + request, + allowLosslessV2: true); + + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext( + chatAgent, + session, + request.Messages, + [new ChatMessage(ChatRole.Assistant, "response")])); + + Assert.True(provider.HasStagedTurn); + Assert.Equal(2, state.Data.ConversationHistory.Count); + } + + [Fact] + public async Task FrameworkLocalConversationSentinelStillStagesEntityHistoryAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync( + DurableAgentHistoryBinding.FrameworkLocalHistoryConversationId); + DurableAgentState state = new(); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new( + state.Data.ConversationHistory, + request, + allowLosslessV2: true); + + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext( + chatAgent, + session, + request.Messages, + [new ChatMessage(ChatRole.Assistant, "response")])); + + Assert.True(provider.HasStagedTurn); + Assert.Equal(2, state.Data.ConversationHistory.Count); + } + + [Fact] + public async Task V2ProviderStagesDeveloperRoleResponseLosslesslyAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync(); + DurableAgentState state = new(); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new( + state.Data.ConversationHistory, + request, + allowLosslessV2: true); + + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext( + chatAgent, + session, + request.Messages, + [new ChatMessage(new ChatRole("developer"), "developer response")])); + + DurableAgentStateResponse response = + Assert.IsType(state.Data.ConversationHistory[1]); + Assert.Equal("developer", Assert.Single(response.Messages).Role); + + provider.CompleteStagedResponse( + new AgentResponse( + new ChatMessage(new ChatRole("developer"), "developer response"))); + Assert.Equal( + "developer", + Assert.Single( + Assert.IsType( + state.Data.ConversationHistory[1]).Messages).Role); + } + + [Fact] + public async Task EntityWrapperUsesDurableProviderThroughDelegatingAgentAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AIAgent wrappedAgent = new TestDelegatingAgent(chatAgent); + AgentSession session = await wrappedAgent.CreateSessionAsync(); + DurableAgentState state = CreateStateWithExchange("old", "old request", "old response"); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + Mock context = new(); + context.SetupGet(value => value.Id).Returns(new EntityInstanceId("dafx-test-agent", "session")); + EntityAgentWrapper wrapper = new(wrappedAgent, context.Object, request, chatHistoryProvider: provider); + + AgentResponse response = await wrapper.RunAsync(request.Messages, session); + provider.CompleteStagedResponse(response); + + Assert.Equal(3, client.LastMessages.Count); + Assert.Equal(["old request", "old response", "new request"], client.LastMessages.Select(message => message.Text)); + Assert.True(provider.HasStagedTurn); + Assert.Equal(4, state.Data.ConversationHistory.Count); + } + + [Fact] + public async Task ServiceManagedSessionDoesNotStoreTranscriptAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync("service-conversation"); + DurableAgentState state = new(); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext( + chatAgent, + session, + request.Messages, + [new ChatMessage(ChatRole.Assistant, "response")])); + + Assert.False(provider.HasStagedTurn); + Assert.Empty(state.Data.ConversationHistory); + } + + [Fact] + public async Task ProviderStoresOnlyNonHistoryRequestMessagesAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync(); + DurableAgentState state = CreateStateWithExchange("old", "old request", "old response"); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + + IEnumerable merged = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(chatAgent, session, request.Messages)); + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext( + chatAgent, + session, + merged, + [new ChatMessage(ChatRole.Assistant, "new response")])); + + DurableAgentStateRequest storedRequest = + Assert.IsType(state.Data.ConversationHistory[^2]); + Assert.Single(storedRequest.Messages); + Assert.Equal("new request", storedRequest.Messages[0].ToChatMessage().Text); + Assert.Equal(4, state.Data.ConversationHistory.Count); + Assert.Single( + state.Data.ConversationHistory.OfType(), + entry => entry.CorrelationId == "new"); + } + + [Fact] + public async Task ProviderReplaysCompactionButNotErrorResponseAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync(); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateErrorResponse + { + CorrelationId = "failed", + CreatedAt = DateTimeOffset.UtcNow, + Messages = [DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.Assistant, "error"))], + }); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = DateTimeOffset.UtcNow, + Messages = [DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.Assistant, "summary"))], + }); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + + IEnumerable messages = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(chatAgent, session, request.Messages)); + + Assert.Equal(["summary", "new request"], messages.Select(message => message.Text)); + } + + [Fact] + public async Task ProviderDropsReasoningOnlyMessagesAndKeepsOtherMixedContentAsync() + { + ChatClientAgent chatAgent = new(new RecordingChatClient(), name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync(); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateResponse + { + CorrelationId = "old", + CreatedAt = DateTimeOffset.UtcNow, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = + [ + new DurableAgentStateTextReasoningContent { Text = "reasoning only" }, + ], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = + [ + new DurableAgentStateTextReasoningContent { Text = "private reasoning" }, + new DurableAgentStateTextContent { Text = "visible answer" }, + ], + }, + ], + }); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + + List messages = (await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext( + chatAgent, + session, + request.Messages))).ToList(); + + Assert.Equal(["visible answer", "new request"], messages.Select(message => message.Text)); + Assert.DoesNotContain( + messages.SelectMany(message => message.Contents), + content => content is TextReasoningContent); + } + + [Fact] + public async Task ProviderDoesNotReplayMetadataOnlyRequestEnvelopesAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync(); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CorrelationId = "old", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.User.Value, + Contents = [], + }, + ], + }); + state.Data.ConversationHistory.Add( + DurableAgentStateResponse.FromResponse( + "old", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "old response")))); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + + IEnumerable messages = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(chatAgent, session, request.Messages)); + + Assert.Equal(["old response", "new request"], messages.Select(message => message.Text)); + } + + [Fact] + public async Task ProviderPersistsButDoesNotReplayMetadataOnlyResponsesAsync() + { + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "test-agent"); + AgentSession session = await chatAgent.CreateSessionAsync(); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + DurableAgentStateResponse.FromResponse( + "old", + new AgentResponse( + [ + new ChatMessage(ChatRole.Assistant, []) + { + MessageId = "metadata-only", + AdditionalProperties = new() { ["status"] = "complete" }, + }, + new ChatMessage(ChatRole.Assistant, "old response"), + ]))); + RunRequest request = new("new request") { CorrelationId = "new" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + + IEnumerable messages = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(chatAgent, session, request.Messages)); + + DurableAgentStateResponse stored = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("metadata-only", stored.Messages[0].MessageId); + Assert.Equal(["old response", "new request"], messages.Select(message => message.Text)); + } + + [Fact] + public async Task ProviderMigratesIdsBeforeReplayFilteringAsync() + { + DateTimeOffset compactionTime = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + DurableAgentState state = new(); + DurableAgentStateRequest requestEntry = new() + { + CorrelationId = "old", + CreatedAt = compactionTime.AddSeconds(-2), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.User.Value, + Contents = [new DurableAgentStateTextContent { Text = "old request" }], + }, + ], + }; + DurableAgentStateResponse responseEntry = new() + { + CorrelationId = "old", + CreatedAt = compactionTime.AddSeconds(-1), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextContent { Text = "old response" }], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextReasoningContent { Text = "reasoning" }], + }, + ], + }; + DurableAgentStateErrorResponse errorEntry = new() + { + CorrelationId = "failed", + CreatedAt = compactionTime, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextContent { Text = "error" }], + }, + ], + }; + DurableAgentStateCompaction compactionEntry = new() + { + CreatedAt = compactionTime, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextContent { Text = "summary" }], + }, + ], + }; + state.Data.ConversationHistory.Add(requestEntry); + state.Data.ConversationHistory.Add(responseEntry); + state.Data.ConversationHistory.Add(errorEntry); + state.Data.ConversationHistory.Add(compactionEntry); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CorrelationId = "current", + CreatedAt = compactionTime, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.User.Value, + Contents = [new DurableAgentStateTextContent { Text = "must not duplicate" }], + }, + ], + }); + RunRequest request = new("new request") { CorrelationId = "current" }; + DurableChatHistoryProvider provider = new(state.Data.ConversationHistory, request); + ChatClientAgent agent = new(new RecordingChatClient(), name: "test-agent"); + AgentSession session = await agent.CreateSessionAsync(); + + IEnumerable messages = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(agent, session, request.Messages)); + + Assert.Equal(["old request", "old response", "summary", "new request"], messages.Select(message => message.Text)); + Assert.Equal("durable_request_old_0", requestEntry.Messages[0].MessageId); + Assert.Equal("durable_response_old_0", responseEntry.Messages[0].MessageId); + Assert.Equal("durable_response_old_1", responseEntry.Messages[1].MessageId); + Assert.Equal("durable_response_old_2", responseEntry.Messages[2].MessageId); + Assert.Equal("durable_errorResponse_failed_0", errorEntry.Messages[0].MessageId); + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + compactionEntry.Messages[0].MessageId); + + string serialized = System.Text.Json.JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + DurableAgentState restored = Assert.IsType( + System.Text.Json.JsonSerializer.Deserialize( + serialized, + DurableAgentStateJsonContext.Default.DurableAgentState)); + + Assert.Equal( + state.Data.ConversationHistory.SelectMany(entry => entry.Messages).Select(message => message.MessageId), + restored.Data.ConversationHistory.SelectMany(entry => entry.Messages).Select(message => message.MessageId)); + } + + private static DurableAgentState CreateStateWithExchange( + string correlationId, + string request, + string response) + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + DurableAgentStateRequest.FromRunRequest( + new RunRequest(request) { CorrelationId = correlationId })); + state.Data.ConversationHistory.Add( + DurableAgentStateResponse.FromResponse( + correlationId, + new AgentResponse(new ChatMessage(ChatRole.Assistant, response)))); + return state; + } + + private sealed class TestDelegatingAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent); + + private sealed class RecordingChatClient : IChatClient + { + public List LastMessages { get; private set; } = []; + + 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) + { + this.LastMessages = messages.ToList(); + return Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "response"))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.LastMessages = messages.ToList(); + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "response"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/EntityAgentWrapperTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/EntityAgentWrapperTests.cs new file mode 100644 index 0000000..ae1a599 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/EntityAgentWrapperTests.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class EntityAgentWrapperTests +{ + [Fact] + public async Task DurableIdentityOverridesInnerIdentityForResponsesAndUpdatesAsync() + { + IdentityAgent innerAgent = new(); + Mock context = CreateContext(); + EntityAgentWrapper wrapper = new( + innerAgent, + context.Object, + new RunRequest("request") { CorrelationId = "correlation" }); + AgentSession session = await wrapper.CreateSessionAsync(); + string expectedAgentId = context.Object.Id.ToString(); + + AgentResponse response = await wrapper.RunAsync("request", session); + List updates = []; + await foreach (AgentResponseUpdate update in wrapper.RunStreamingAsync("request", session)) + { + updates.Add(update); + } + + Assert.Equal(expectedAgentId, response.AgentId); + Assert.All(updates, update => Assert.Equal(expectedAgentId, update.AgentId)); + Assert.Equal(expectedAgentId, updates.ToAgentResponse().AgentId); + } + + [Fact] + public async Task OperationScopedProviderInstanceOverridesConfiguredProviderWithoutMutatingCallerOptionsAsync() + { + RecordingHistoryProvider configuredProvider = new(); + RecordingHistoryProvider operationProvider = new(); + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new( + client, + new ChatClientAgentOptions + { + Name = "agent", + ChatHistoryProvider = configuredProvider, + }); + AgentSession session = await chatAgent.CreateSessionAsync(); + Mock context = CreateContext(); + EntityAgentWrapper wrapper = new( + chatAgent, + context.Object, + new RunRequest("request") { CorrelationId = "correlation" }, + chatHistoryProvider: operationProvider); + ChatClientAgentRunOptions callerOptions = new() + { + AdditionalProperties = new() { ["caller"] = "preserved" }, + }; + + _ = await wrapper.RunAsync("request", session, callerOptions); + + Assert.Equal(1, operationProvider.LoadCount); + Assert.Equal(1, operationProvider.StoreCount); + Assert.Equal(0, configuredProvider.LoadCount); + Assert.Equal(0, configuredProvider.StoreCount); + Assert.Equal(["operation history", "request"], client.LastMessages.Select(message => message.Text)); + Assert.False(callerOptions.AdditionalProperties.Contains()); + Assert.Equal("preserved", callerOptions.AdditionalProperties["caller"]); + } + + [Fact] + public async Task ExistingProviderOverrideIsRejectedWithoutMutatingCallerOptionsAsync() + { + RecordingHistoryProvider existingProvider = new(); + RecordingHistoryProvider operationProvider = new(); + RecordingChatClient client = new(); + ChatClientAgent chatAgent = new(client, name: "agent"); + AgentSession session = await chatAgent.CreateSessionAsync(); + Mock context = CreateContext(); + EntityAgentWrapper wrapper = new( + chatAgent, + context.Object, + new RunRequest("request") { CorrelationId = "correlation" }, + chatHistoryProvider: operationProvider); + ChatClientAgentRunOptions callerOptions = new() + { + AdditionalProperties = [], + }; + callerOptions.AdditionalProperties.Add(existingProvider); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => wrapper.RunAsync("request", session, callerOptions)); + + Assert.Contains("already present", exception.Message, StringComparison.Ordinal); + Assert.True(callerOptions.AdditionalProperties.TryGetValue( + out ChatHistoryProvider? retainedProvider)); + Assert.Same(existingProvider, retainedProvider); + Assert.Equal(0, client.InvocationCount); + Assert.Equal(0, operationProvider.LoadCount); + } + + private static Mock CreateContext() + { + Mock context = new(); + context.SetupGet(value => value.Id).Returns( + new EntityInstanceId("dafx-agent", "session")); + return context; + } + + private sealed class IdentityAgent : AIAgent + { + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => new(new IdentitySession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => new(new IdentitySession()); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult( + new AgentResponse(new ChatMessage(ChatRole.Assistant, "response")) + { + AgentId = "inner-agent-id", + }); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, "response") + { + AgentId = "inner-agent-id", + }; + } + + private sealed class IdentitySession : AgentSession; + } + + private sealed class RecordingHistoryProvider : ChatHistoryProvider + { + public int LoadCount { get; private set; } + + public int StoreCount { get; private set; } + + protected override ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + this.LoadCount++; + return new([new ChatMessage(ChatRole.User, "operation history")]); + } + + protected override ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + { + this.StoreCount++; + return default; + } + } + + private sealed class RecordingChatClient : IChatClient + { + public int InvocationCount { get; private set; } + + public List LastMessages { get; private set; } = []; + + public void Dispose() + { + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + this.InvocationCount++; + this.LastMessages = messages.ToList(); + return Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "response"))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.InvocationCount++; + this.LastMessages = messages.ToList(); + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "response"); + } + } +}