diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs index e87f17b..e5d57bf 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using Microsoft.Agents.AI.DurableTask.State; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Entities; @@ -12,15 +13,41 @@ namespace Microsoft.Agents.AI.DurableTask; internal class AgentEntity(IServiceProvider services, CancellationToken cancellationToken = default) : TaskEntity { + 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 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 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 : services.GetService()?.ApplicationStopping ?? CancellationToken.None; + protected override DurableAgentState InitializeState(TaskEntityOperation entityOperation) + { + return this.MailboxWritesEnabled && + entityOperation.Name is nameof(Run) or nameof(RunAgentAsync) + ? new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(StringComparer.Ordinal), + CompletionReceipts = new Dictionary(StringComparer.Ordinal), + }, + } + : base.InitializeState(entityOperation); + } + public Task RunAgentAsync(RunRequest request) { return this.Run(request); @@ -33,20 +60,152 @@ public async Task Run(RunRequest request) #pragma warning restore VSTHRD200 #pragma warning restore IDE1006 { + ArgumentNullException.ThrowIfNull(request); + AgentSessionId sessionId = this.Context.Id; - AIAgent agent = this.GetAgent(sessionId); - EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services); + // Logger category is Microsoft.DurableTask.Agents.{registeredAgentName}.{sessionId} + ILogger logger = this.GetLogger(sessionId.Name, sessionId.Key); + + string correlationId = request.CorrelationId; + if (string.IsNullOrWhiteSpace(correlationId)) + { + throw new ArgumentException( + "A non-empty correlation ID is required to run a durable agent request.", + nameof(request)); + } + + DateTimeOffset currentTime = this._timeProvider.GetUtcNow(); + DurableAgentRunOutcome existingOutcome; + try + { + existingOutcome = DurableAgentStateOutcomeResolver.Resolve( + this.State, + correlationId, + currentTime); + } + catch (DurableAgentStateCorruptionException exception) + { + logger.LogDurableOutcomeStateCorruption( + exception, + sessionId, + correlationId); + throw; + } + + if (existingOutcome.Kind != DurableAgentRunOutcomeKind.Pending) + { + // Correlation is the caller's idempotency key. Retained terminal state is reused + // without comparing request content, so callers must not reuse it for another request. + // Surface failures before optional migration so failed delivery never writes state. + AgentResponse committedResponse = existingOutcome.GetResponse(correlationId); + bool legacyMailboxMigrationRequested = + this.MailboxWritesEnabled && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion; + bool migrationAuthorized = + legacyMailboxMigrationRequested && + this._options.AuthorizeLegacyMigration?.Invoke(this.State) == true; + if (this._options.HistoryRetentionMode == DurableAgentHistoryRetentionMode.Auto && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion && + !migrationAuthorized) + { + throw new DurableAgentStateCorruptionException( + "Automatic history retention requires schema 2 mailbox state. Legacy terminal transcript " + + "entries must be converted from independently authoritative complete history before delivery."); + } - // Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId} - ILogger logger = this.GetLogger(agent.Name!, sessionId.Key); + if (legacyMailboxMigrationRequested && + migrationAuthorized && + existingOutcome.Kind != DurableAgentRunOutcomeKind.CompletedResultUnavailable) + { + // Legacy evidence is converted without constructing or invoking the agent. + DurableAgentState migrated = DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState( + this.State, hasAuthoritativeLegacyHistory: true); + this.ApplyRetentionAndCommit( + migrated, + sessionId, + logger, + deletionCheckExpiration: null); + } + + return committedResponse; + } + + if (request.Messages is not { Count: > 0 }) + { + throw new ArgumentException( + "At least one message is required for a new durable agent request.", + nameof(request)); + } + + if (this.MailboxWritesEnabled) + { + DurableAgentStateContract.ValidateIdentifier(correlationId, "correlationId"); + } - if (request.Messages.Count == 0) + if (!this.MailboxWritesEnabled && + this.State.SchemaVersion == DurableAgentState.RevisedSchemaVersion) { - logger.LogInformation("Ignoring empty request"); - return new AgentResponse(); + throw new InvalidOperationException("New mailbox requests require EnableMailboxWrites to be enabled."); } - this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request)); + this._cancellationToken.ThrowIfCancellationRequested(); + // TaskEntity hydrates State with the backend-owned object. Mutate an independent copy so + // an exception leaves the hydrated state unchanged. + bool migrateLegacy = this.MailboxWritesEnabled && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion && + this._options.AuthorizeLegacyMigration?.Invoke(this.State) == true; + if (this._options.HistoryRetentionMode == DurableAgentHistoryRetentionMode.Auto && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion && + !migrateLegacy) + { + throw new DurableAgentStateCorruptionException( + "Automatic history retention requires schema 2 mailbox state. Legacy terminal transcript " + + "entries must be converted from independently authoritative complete history before execution."); + } + + DurableAgentState workingState = migrateLegacy + ? DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState(this.State, hasAuthoritativeLegacyHistory: true) + : this.State.Clone(); + if (this.MailboxWritesEnabled && + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion) + { + workingState.MailboxWritesAuthorized = true; + } + + bool isLegacyState = + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion; + DurableAgentStateHistoryBinding? persistedHistoryBinding = + DurableAgentHistoryBinding.Parse(this.State.Data.HistoryBinding); + DurableAgentStateHistoryBinding? existingHistoryBinding = + DurableAgentHistoryBinding.IsSealedByCSharp(persistedHistoryBinding) + ? persistedHistoryBinding + : null; + string? configuredHistoryProviderKey = + this._options.GetHistoryProviderKey(sessionId.Name) ?? + (existingHistoryBinding is null + ? persistedHistoryBinding?.ProviderKey + : null); + 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); + 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) { @@ -64,30 +223,115 @@ 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); + 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 (isLegacyState) + { + 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, + historyReplayMode, + workingState.SchemaVersion != DurableAgentState.RevisedSchemaVersion); + // Start the agent response stream IAsyncEnumerable responseStream = agentWrapper.RunStreamingAsync( - this.State.Data.ConversationHistory.SelectMany(e => e.Messages).Select(m => m.ToChatMessage()), - await agentWrapper.CreateSessionAsync(cancellationToken).ConfigureAwait(false), + inputMessages, + session, options: null, this._cancellationToken); +#pragma warning disable MEAI001 // Preserve the continuation token omitted by response stream aggregation. + ResponseContinuationToken? continuationToken = null; + async IAsyncEnumerable CaptureResponseMetadataAsync() + { + await foreach (AgentResponseUpdate update in responseStream) + { + continuationToken = update.ContinuationToken ?? continuationToken; + yield return update; + } + } +#pragma warning restore MEAI001 + AgentResponse response; if (this._messageHandler is null) { // If no message handler is provided, we can just get the full response at once. // This is expected to be the common case for non-interactive agents. - response = await responseStream.ToAgentResponseAsync(this._cancellationToken); + response = await CaptureResponseMetadataAsync().ToAgentResponseAsync(this._cancellationToken); } else { List responseUpdates = []; + bool streamCompleted = false; // To support interactive chat agents, we need to stream the responses to an IAgentMessageHandler. // The user-provided message handler can be implemented to send the responses to the user. // We assume that only non-empty text updates are useful for the user. async IAsyncEnumerable StreamResultsAsync() { - await foreach (AgentResponseUpdate update in responseStream) + await foreach (AgentResponseUpdate update in CaptureResponseMetadataAsync()) { // We need the full response further down, so we piece it together as we go. responseUpdates.Add(update); @@ -95,15 +339,114 @@ async IAsyncEnumerable StreamResultsAsync() // Yield the update to the message handler. yield return update; } + + streamCompleted = true; } await this._messageHandler.OnStreamingResponseUpdateAsync(StreamResultsAsync(), this._cancellationToken); + if (!streamCompleted) + { + throw new InvalidOperationException( + "The agent response handler must consume the complete response stream before the run can commit."); + } + response = responseUpdates.ToAgentResponse(); } - // Persist the agent response to the entity state for client polling - this.State.Data.ConversationHistory.Add( - DurableAgentStateResponse.FromResponse(request.CorrelationId, response)); +#pragma warning disable MEAI001 // Preserve the caller-visible token as well as the mailbox snapshot. + response.ContinuationToken = continuationToken; +#pragma warning restore MEAI001 + + (DurableAgentHistoryOwnership finalOwnership, _) = + DurableAgentHistoryOwnershipResolver.Resolve( + session, + validatedHistoryConfiguration); + finalOwnership = DurableAgentHistoryOwnershipResolver.GetEffectiveOwnership( + finalOwnership, + historyReplayMode); + bool remoteServiceTransition = + finalOwnership != effectiveOwnership && + finalOwnership == DurableAgentHistoryOwnership.Service; + DurableAgentStateHistoryBinding finalHistoryBinding = + DurableAgentHistoryBinding.Create( + finalOwnership, + configuredHistoryProviderKey, + remoteServiceTransition); + if (isLegacyState) + { + 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(); + DurableAgentStateOutcomeResolver.AddSuccessfulResult( + workingState, + correlationId, + response, + completedAt, + this._options.ResultRetentionPeriod is TimeSpan retention ? completedAt.Add(retention) : null, + logger: logger); + DurableAgentJsonUtilities.CaptureRetainedResult( + response, workingState.Data.TerminalResults![correlationId].Response!); + } + else if (storedResponse is not null) + { + DurableAgentJsonUtilities.CaptureRetainedLegacyResult(response, storedResponse); + } string responseText = response.Text; @@ -118,38 +461,33 @@ async IAsyncEnumerable StreamResultsAsync() response.Usage?.TotalTokenCount); } - // Update TTL expiration time. Only schedule deletion check on first interaction. - // Subsequent interactions just update the expiration time; CheckAndDeleteIfExpiredAsync - // will reschedule the deletion check when it runs. - TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name); - if (timeToLive.HasValue) - { - DateTime newExpirationTime = DateTime.UtcNow.Add(timeToLive.Value); - bool isFirstInteraction = this.State.Data.ExpirationTimeUtc is null; - - this.State.Data.ExpirationTimeUtc = newExpirationTime; - logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime); - - // Only schedule deletion check on the first interaction when entity is created. - // On subsequent interactions, we just update the expiration time. The scheduled - // CheckAndDeleteIfExpiredAsync will reschedule itself if the entity hasn't expired. - if (isFirstInteraction) - { - this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value); - } - } - else - { - // TTL is disabled. Clear the expiration time if it was previously set. - if (this.State.Data.ExpirationTimeUtc.HasValue) - { - logger.LogTTLExpirationTimeCleared(sessionId); - this.State.Data.ExpirationTimeUtc = null; - } - } - + DateTime? deletionCheckExpiration = + this.UpdateExpiration(workingState, sessionId, logger); + this.ApplyRetentionAndCommit( + workingState, + sessionId, + logger, + deletionCheckExpiration); 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); + throw; + } finally { // Clear the current agent context @@ -163,41 +501,141 @@ async IAsyncEnumerable StreamResultsAsync() /// /// This method is called by the durable task runtime when a CheckAndDeleteIfExpired signal is received. /// - public void CheckAndDeleteIfExpired() + public void CheckAndDeleteIfExpired(AgentEntityDeletionCheck? scheduledCheck = null) { AgentSessionId sessionId = this.Context.Id; - AIAgent agent = this.GetAgent(sessionId); - ILogger logger = this.GetLogger(agent.Name!, sessionId.Key); + ILogger logger = this.GetLogger(sessionId.Name, sessionId.Key); - DateTime currentTime = DateTime.UtcNow; + DateTime currentTime = this._timeProvider.GetUtcNow().UtcDateTime; DateTime? expirationTime = this.State.Data.ExpirationTimeUtc; logger.LogTTLDeletionCheck(sessionId, expirationTime, currentTime); - if (expirationTime.HasValue) + // A delayed signal can outlive a deleted entity. TaskEntity initializes missing state + // before dispatch, so delete that otherwise-empty placeholder instead of recreating it. + if (!expirationTime.HasValue && IsEmptyInitializedState(this.State)) { - if (currentTime >= expirationTime.Value) - { - // Entity has expired, delete it - logger.LogTTLEntityExpired(sessionId, expirationTime.Value); - this.State = null!; - } - else + this.State = null!; + return; + } + + if (this.State.SchemaVersion == DurableAgentState.RevisedSchemaVersion && + !this._options.EnableMailboxEntityDeletion) + { + // A legacy deadline is not authorization to erase completion evidence. + return; + } + + if (!this._options.ContainsAgent(sessionId.Name) || + !this._options.GetTimeToLive( + sessionId.Name, this.State.SchemaVersion == DurableAgentState.RevisedSchemaVersion).HasValue) + { + // Configuration can change while a durable delayed signal is outstanding. + if (expirationTime.HasValue) { - // Entity hasn't expired yet, reschedule the deletion check - TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name); - if (timeToLive.HasValue) + logger.LogTTLExpirationTimeCleared(sessionId); + bool migrateLegacy = + this._options.HistoryRetentionMode == DurableAgentHistoryRetentionMode.Auto && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion && + this._options.AuthorizeLegacyMigration?.Invoke(this.State) == true; + if (this._options.HistoryRetentionMode == DurableAgentHistoryRetentionMode.Auto && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion && + !migrateLegacy) + { + throw new DurableAgentStateCorruptionException( + "Automatic history retention requires schema 2 mailbox state. Legacy terminal transcript " + + "entries must be converted from independently authoritative complete history before TTL mutation."); + } + + DurableAgentState workingState = migrateLegacy + ? DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState( + this.State, + hasAuthoritativeLegacyHistory: true) + : this.State.Clone(); + if (this.MailboxWritesEnabled && + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion) { - this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value); + workingState.MailboxWritesAuthorized = true; } + + workingState.Data.ExpirationTimeUtc = null; + this.ApplyRetentionAndCommit( + workingState, + sessionId, + logger, + deletionCheckExpiration: null); } + + return; + } + + if (!expirationTime.HasValue) + { + return; + } + + if (currentTime >= expirationTime.Value) + { + logger.LogTTLEntityExpired(sessionId, expirationTime.Value); + this.State = null!; + return; + } + + // 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) + { + this.ScheduleDeletionCheck(sessionId, logger, expirationTime.Value); } } - private void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, TimeSpan timeToLive) + private static bool IsEmptyInitializedState(DurableAgentState state) { - DateTime currentTime = DateTime.UtcNow; - DateTime expirationTime = this.State.Data.ExpirationTimeUtc ?? currentTime.Add(timeToLive); + return state.Data.ConversationHistory.Count == 0 && + state.Data.TerminalResults is null && + state.Data.CompletionReceipts is null && + state.Data.HistoryBinding.ValueKind == JsonValueKind.Undefined && + state.Data.Session is null && + state.Data.IngestedPositions is null && + state.Data.Truncation is null && + state.Data.ExpirationTimeUtc is null && + state.Data.ExtensionData is null && + state.Data.UnknownProperties is null && + state.ExtensionData is null && + state.UnknownProperties is null; + } + + private bool MailboxWritesEnabled => + this._options.EnableMailboxWrites || + this._options.HistoryRetentionMode == DurableAgentHistoryRetentionMode.Auto; + + 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, + DateTime expirationTime) + { + DateTime currentTime = this._timeProvider.GetUtcNow().UtcDateTime; TimeSpan minimumDelay = this._options.MinimumTimeToLiveSignalDelay; // To avoid excessive scheduling, we schedule the deletion check for no less than the minimum delay. @@ -211,9 +649,168 @@ private void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, Tim this.Context.SignalEntity( this.Context.Id, nameof(CheckAndDeleteIfExpired), // self-signal + new AgentEntityDeletionCheck(expirationTime), options: new SignalEntityOptions { SignalTime = scheduledTime }); } + private static IEnumerable BuildAgentInputMessages( + DurableAgentState workingState, + RunRequest request, + DurableAgentHistoryOwnership ownership, + ChatClientAgent? chatClientAgent, + DurableAgentHistoryReplayMode historyReplayMode, + bool isLegacyState) + { + if (isLegacyState) + { + return workingState.Data.ConversationHistory + .SelectMany(entry => entry.Messages) + .Select(message => message.ToChatMessage()); + } + + if (chatClientAgent is not null || + 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; + } + + // Legacy 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, + ILogger logger) + { + TimeSpan? timeToLive = this._options.GetTimeToLive( + sessionId.Name, workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion); + DateTime? previousExpirationTime = workingState.Data.ExpirationTimeUtc; + if (!timeToLive.HasValue) + { + if (previousExpirationTime.HasValue) + { + logger.LogTTLExpirationTimeCleared(sessionId); + workingState.Data.ExpirationTimeUtc = null; + } + + return null; + } + + DateTime newExpirationTime = + this._timeProvider.GetUtcNow().UtcDateTime.Add(timeToLive.Value); + workingState.Data.ExpirationTimeUtc = newExpirationTime; + logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime); + + // 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 + : null; + } + + private void ApplyRetentionAndCommit( + DurableAgentState workingState, + AgentSessionId sessionId, + ILogger logger, + DateTime? deletionCheckExpiration) + { + _ = DurableAgentStateRetention.Enforce( + workingState, + this._options.HistoryRetentionMode, + this._options.MaxStateBytes, + this._timeProvider.GetUtcNow(), + logger, + sessionId); + + this._cancellationToken.ThrowIfCancellationRequested(); + ValidateForCommit(workingState); + + if (deletionCheckExpiration.HasValue) + { + // Pass the working-copy value explicitly: this.State still refers to the original state + // until the operation commits. + this.ScheduleDeletionCheck(sessionId, logger, deletionCheckExpiration.Value); + } + + // This setter performs no backend I/O. TaskEntity writes the replacement state only after + // this async operation completes successfully; an exception before then leaves storage unchanged. + this.State = workingState; + } + + private static void ValidateForCommit(DurableAgentState state) + { + // Validate serialization before publishing the replacement. Backend commit remains the + // durable runtime's atomic boundary; external tool/provider writes are outside it. + _ = JsonSerializer.SerializeToUtf8Bytes(state, DurableAgentStateJsonContext.Default.DurableAgentState); + } + private AIAgent GetAgent(AgentSessionId sessionId) { IReadOnlyDictionary> agents = @@ -231,3 +828,5 @@ private ILogger GetLogger(string agentName, string sessionKey) return this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agentName}.{sessionKey}"); } } + +internal sealed record AgentEntityDeletionCheck(DateTime ExpectedExpirationTimeUtc); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs index 0ff3291..873bcb0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs @@ -14,15 +14,20 @@ internal sealed class AgentRunHandle { private readonly DurableTaskClient _client; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; internal AgentRunHandle( DurableTaskClient client, ILogger logger, AgentSessionId sessionId, - string correlationId) + string correlationId, + TimeProvider? timeProvider = null) { + ArgumentException.ThrowIfNullOrWhiteSpace(correlationId); + this._client = client; this._logger = logger; + this._timeProvider = timeProvider ?? TimeProvider.System; this.SessionId = sessionId; this.CorrelationId = correlationId; } @@ -39,12 +44,19 @@ internal AgentRunHandle( /// /// Reads the agent response for this request by polling the entity state until the response is found. - /// Uses an exponential backoff polling strategy with a maximum interval of 1 second. + /// Uses an exponential backoff polling strategy with a maximum interval of 3 seconds. /// /// The cancellation token. /// The agent response corresponding to this request. /// Thrown when the response is not found after polling. public async Task ReadAgentResponseAsync(CancellationToken cancellationToken = default) + { + DurableAgentRunOutcome outcome = await this.ReadAgentOutcomeAsync(cancellationToken); + return outcome.GetResponse(this.CorrelationId); + } + + internal async Task ReadAgentOutcomeAsync( + CancellationToken cancellationToken = default) { TimeSpan pollInterval = TimeSpan.FromMilliseconds(50); // Start with 50ms TimeSpan maxPollInterval = TimeSpan.FromSeconds(3); // Maximum 3 seconds @@ -59,17 +71,29 @@ public async Task ReadAgentResponseAsync(CancellationToken cancel cancellation: cancellationToken); DurableAgentState? state = entityResponse?.State; - if (state?.Data.ConversationHistory is not null) + if (state is not null) { - // Look for an agent response with matching CorrelationId - DurableAgentStateResponse? response = state.Data.ConversationHistory - .OfType() - .FirstOrDefault(r => r.CorrelationId == this.CorrelationId); + DurableAgentRunOutcome outcome; + try + { + outcome = DurableAgentStateOutcomeResolver.Resolve( + state, + this.CorrelationId, + this._timeProvider.GetUtcNow()); + } + catch (DurableAgentStateCorruptionException exception) + { + this._logger.LogDurableOutcomeStateCorruption( + exception, + this.SessionId, + this.CorrelationId); + throw; + } - if (response is not null) + if (outcome.Kind != DurableAgentRunOutcomeKind.Pending) { this._logger.LogDonePollingForResponse(this.SessionId, this.CorrelationId); - return response.ToResponse(); + return outcome; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 062e685..2718fb2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,7 +2,10 @@ ## [Unreleased] +- Added opt-in pressure-based durable transcript retention and low-cardinality operational metrics while protecting schema 2 mailbox and execution-control state. +- Hardened durable-agent mailbox delivery, duplicate correlation handling, working-state rollback, and stale-safe TTL deletion scheduling; preserved historical message boundaries, opaque state profiles, and committed failure metadata; isolated untrusted response text from workflow controls ([#94](https://github.com/microsoft/agent-framework-durable-extension/pull/94)) - Fail durable workflows with a `MaxSuperstepsExceededException` when they reach the configurable `MaxSupersteps` limit with work still queued, instead of returning a successful partial result ([#84](https://github.com/microsoft/agent-framework-durable-extension/pull/84)) +- Added passive .NET DTO, converter, validation, and source-generation support for the proposed durable agent state 2.0 contract ([tamirdresher/agent-framework-durable-extension#1](https://github.com/tamirdresher/agent-framework-durable-extension/pull/1)) - Fixed `ConfigureDurableAgents` and `ConfigureDurableWorkflows` ignoring the `workerBuilder` or `clientBuilder` supplied to a later call when no earlier call supplied one, so the Durable Task worker and client are now registered whichever configuration call provides them. The first non-null delegate wins; later ones are still ignored so a builder passed to several calls is only applied once. Registering an agent that a workflow already referenced now promotes it to an explicitly registered agent instead of throwing, so agents and workflows can be configured in either order ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67)) - [BREAKING] Fixed `AddWorkflow` silently overwriting an existing workflow registered under the same name, which left the workflow and executor registries inconsistent. Registering a different workflow under a name that is already taken now throws, while re-registering the same workflow instance remains a no-op. An application that registers duplicate workflow names starts today but will now fail at startup ([#66](https://github.com/microsoft/agent-framework-durable-extension/pull/66)) - Fixed a `JsonTypeInfo metadata ... was not provided` failure when persisting agent state for function calls or results that carry values the state serializer has no metadata for, such as the `AIContent` results returned by MCP tools ([#57](https://github.com/microsoft/agent-framework-durable-extension/pull/57)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs index 9005641..b84d985 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs @@ -6,10 +6,14 @@ namespace Microsoft.Agents.AI.DurableTask; -internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactory loggerFactory) : IDurableAgentClient +internal class DefaultDurableAgentClient( + DurableTaskClient client, + ILoggerFactory loggerFactory, + TimeProvider? timeProvider = null) : IDurableAgentClient { private readonly DurableTaskClient _client = client ?? throw new ArgumentNullException(nameof(client)); private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; public async Task RunAgentAsync( AgentSessionId sessionId, @@ -17,6 +21,13 @@ public async Task RunAgentAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); + string correlationId = request.CorrelationId; + if (string.IsNullOrWhiteSpace(correlationId)) + { + throw new ArgumentException( + "A non-empty correlation ID is required to run a durable agent request.", + nameof(request)); + } this._logger.LogSignallingAgent(sessionId); @@ -26,6 +37,6 @@ await this._client.Entities.SignalEntityAsync( request, cancellation: cancellationToken); - return new AgentRunHandle(this._client, this._logger, sessionId, request.CorrelationId); + return new AgentRunHandle(this._client, this._logger, sessionId, correlationId, this._timeProvider); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index 599ea37..10a202e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -142,6 +142,10 @@ protected override async Task RunCoreAsync( { throw new AgentNotRegisteredException(this._agentName, e); } + catch (Exception e) when (DurableAgentFailure.TryRestore(e, out Exception? failure)) + { + throw failure; + } } /// @@ -291,6 +295,8 @@ protected override async IAsyncEnumerable RunCoreStreamingA // the orchestration. AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken); - return new AgentResponse(response, serializerOptions) { IsWrappedInObject = isWrappedInObject }; + AgentResponse typedResponse = new(response, serializerOptions) { IsWrappedInObject = isWrappedInObject }; + DurableAgentJsonUtilities.CopyRetainedResult(response, typedResponse); + return typedResponse; } } 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/DurableAgentFailure.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentFailure.cs new file mode 100644 index 0000000..d48fe0f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentFailure.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace Microsoft.Agents.AI.DurableTask; + +// Durable Task serializes exception type, message, and inner failure, but not custom CLR +// properties. This framework-only inner exception carries a versioned metadata snapshot. +internal static class DurableAgentFailure +{ + internal static Exception CreateMetadataException(DurableAgentFailureData data, Exception? innerException = null) => + new DurableAgentFailureMetadataException( + JsonSerializer.Serialize(data, DurableAgentJsonUtilities.JsonContext.Default.DurableAgentFailureData), innerException); + + internal static bool TryRestore(Exception exception, [NotNullWhen(true)] out Exception? restored) + { + restored = null; + TaskFailureDetails? failure = exception switch + { + EntityOperationFailedException entityFailure => entityFailure.FailureDetails, + TaskFailedException taskFailure => taskFailure.FailureDetails, + _ => null, + }; + bool terminal = failure?.ErrorType == typeof(DurableAgentTerminalException).FullName; + bool unavailable = failure?.ErrorType == typeof(DurableAgentResultUnavailableException).FullName; + if (!terminal && !unavailable) + { + return false; + } + + TaskFailureDetails? metadata = failure!.InnerFailure; + if (metadata?.ErrorType != typeof(DurableAgentFailureMetadataException).FullName) + { + // Older or message-only failures still fail with a typed exception. Never try + // to extract a contract from their human/model-authored error message. + restored = terminal + ? new DurableAgentTerminalException(failure.ErrorMessage, exception) + : new DurableAgentResultUnavailableException(failure.ErrorMessage, exception); + return true; + } + + try + { + DurableAgentFailureData? data = JsonSerializer.Deserialize( + metadata!.ErrorMessage, DurableAgentJsonUtilities.JsonContext.Default.DurableAgentFailureData); + if (data is null || data.Version != 1 || string.IsNullOrWhiteSpace(data.CorrelationId)) + { + return false; + } + + if (terminal && !string.IsNullOrEmpty(data.Code) && data.SerializedResponse is not null) + { + AgentResponse? response = new DurableDataConverter().Deserialize(data.SerializedResponse, typeof(AgentResponse)) as AgentResponse; + if (response is not null) + { + restored = new DurableAgentTerminalException( + data.CorrelationId, data.Code, failure.ErrorMessage, data.Details, response, exception); + } + } + else if (unavailable && data.CompletedAt is DateTimeOffset completedAt && + data.Outcome is DurableAgentStateCompletionReceipt.SucceededOutcome or DurableAgentStateCompletionReceipt.FailedOutcome) + { + restored = new DurableAgentResultUnavailableException( + data.CorrelationId, completedAt, data.ResultExpiresAt, data.Outcome, exception); + } + } + catch (JsonException) + { + // Unsupported/malformed metadata must leave the original SDK failure intact. + } + catch (InvalidOperationException) + { + // Includes invalid canonical response metadata; never degrade it to success. + } + + return restored is not null; + } +} + +internal sealed class DurableAgentFailureData +{ + public required int Version { get; init; } + + public required string CorrelationId { get; init; } + + public string? Code { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Details { get; init; } + + public string? SerializedResponse { get; init; } + + public DateTimeOffset? CompletedAt { get; init; } + + public DateTimeOffset? ResultExpiresAt { get; init; } + + public string? Outcome { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentFailureMetadataException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentFailureMetadataException.cs new file mode 100644 index 0000000..8d430b1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentFailureMetadataException.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Carries framework-serialized metadata as the inner exception of a durable agent failure. +/// +/// +/// Durable Task retains exception messages and inner failures, but not custom exception properties. +/// Applications should handle the enclosing or +/// , rather than interpret this transport payload. +/// +public sealed class DurableAgentFailureMetadataException : Exception +{ + /// Initializes an empty instance. + public DurableAgentFailureMetadataException() + { + } + + /// Initializes an instance with a message. + public DurableAgentFailureMetadataException(string? message) + : base(message) + { + } + + /// Initializes an instance with a message and inner exception. + public DurableAgentFailureMetadataException(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..0100aa4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryBinding.cs @@ -0,0 +1,403 @@ +// 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"; + 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 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 (ownership == DurableAgentHistoryOwnership.Entity || + !HasPriorContinuity(legacyState)) + { + return; + } + + bool continuityProven = ownership switch + { + DurableAgentHistoryOwnership.Service => + !requiresPerServiceCallPersistence && + restoredSession is ChatClientAgentSession serviceSession && + !string.IsNullOrWhiteSpace(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 && + !string.IsNullOrWhiteSpace(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 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.Any( + key => stateBag.TryGetProperty(key, out _)); + } +} 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..1d7116d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryOwnership.cs @@ -0,0 +1,173 @@ +// 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 && + !string.IsNullOrWhiteSpace(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 not null && HasStatefulCompaction(chatClientAgent)) + { + throw new DurableAgentCompactionNotSupportedException(); + } + } + + 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/DurableAgentHistoryRetentionMode.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryRetentionMode.cs new file mode 100644 index 0000000..f491850 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentHistoryRetentionMode.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Controls how durable agent conversation state is retained. +/// +public enum DurableAgentHistoryRetentionMode +{ + /// + /// Never proactively removes conversation entries. Persistence can still fail when a backend or provider + /// state limit is reached. + /// + KeepAll, + + /// + /// Removes the oldest eligible exchanges when serialized entity state reaches the configured high watermark. + /// + /// + /// Only conversation transcript entries are eligible. Mailbox results, completion receipts, history + /// binding, provider continuation, TTL, and other execution-control state are protected. The newest + /// transcript exchange and system messages are never evicted; if protected state cannot fit below the + /// safe write threshold, the operation fails instead of persisting oversized state. + /// + Auto, +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs index 7670b9e..5c27ac8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; @@ -29,11 +30,65 @@ namespace Microsoft.Agents.AI.DurableTask; /// internal static partial class DurableAgentJsonUtilities { + private static readonly ConditionalWeakTable s_retainedResults = new(); + /// /// Gets the singleton used for Durable Agent serialization. /// public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + /// + /// Gets the canonical retained terminal-response JSON associated with a durable delivery response. + /// + /// + /// The snapshot preserves absent versus explicit-null value, opaque content, and unknown + /// metadata independently of the native projection. It is not inferred + /// from text, stored in producer-defined additional properties, or serialized as part of the native + /// response. The durable data converter explicitly transports and restores this association. + /// + /// The response returned by durable polling or its proxy. + /// The immutable retained JSON, or null for a response not produced by durable delivery. + internal static JsonElement? GetRetainedResult(AgentResponse response) + { + ArgumentNullException.ThrowIfNull(response); + return s_retainedResults.TryGetValue(response, out RetainedResult? retained) ? retained.Value : null; + } + + internal static void CaptureRetainedResult( + AgentResponse response, + DurableAgentStateTerminalResponse terminalResponse) + { + JsonElement snapshot = JsonSerializer.SerializeToElement( + terminalResponse, DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResponse); + CaptureRetainedResult(response, snapshot); + } + + internal static void CaptureRetainedResult(AgentResponse response, JsonElement snapshot) => + s_retainedResults.Add(response, new RetainedResult(snapshot.Clone())); + + internal static void CaptureRetainedLegacyResult(AgentResponse response, DurableAgentStateResponse source) => + CaptureRetainedResult(response, new DurableAgentStateTerminalResponse + { + Messages = source.Messages, + Usage = source.Usage, + CreatedAt = source.CreatedAt, + AdditionalProperties = source.ExtensionData, + UnknownProperties = source.UnknownProperties, + }); + + internal static void CopyRetainedResult(AgentResponse source, AgentResponse target) + { + if (GetRetainedResult(source) is JsonElement result) + { + CaptureRetainedResult(target, result); + } + } + + private sealed class RetainedResult(JsonElement value) + { + public JsonElement Value { get; } = value; + } + /// /// Serializes a sequence of chat messages using the durable agent default options. /// @@ -89,6 +144,8 @@ private static JsonSerializerOptions CreateDefaultOptions() // Request Types [JsonSerializable(typeof(RunRequest))] + [JsonSerializable(typeof(AgentEntityDeletionCheck))] + [JsonSerializable(typeof(DurableAgentFailureData))] // Primitive / Supporting Types [JsonSerializable(typeof(ChatMessage))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentResponseExtensions.cs new file mode 100644 index 0000000..c8d1569 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentResponseExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.DurableTask; + +/// Provides access to canonical results retained by durable agent delivery. +public static class DurableAgentResponseExtensions +{ + /// + /// Gets the immutable canonical terminal-response JSON accompanying a durable agent response. + /// + /// + /// This preserves stored response metadata, opaque content, and an independently supplied + /// value. An absent value property differs from explicit JSON null. No value is + /// inferred from text. Native serialization is unchanged; the + /// registered durable data converter transports this canonical result across durable calls. + /// Serializing through an unrelated serializer or reconstructing the response does not retain + /// that association. + /// + /// The response returned by a durable agent or proxy. + /// Canonical result JSON, or null when no retained result accompanies the response. + public static JsonElement? GetDurableResult(this AgentResponse response) => + DurableAgentJsonUtilities.GetRetainedResult(response); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentResultUnavailableException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentResultUnavailableException.cs new file mode 100644 index 0000000..31001ec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentResultUnavailableException.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// The exception thrown when a durable request completed but its result payload is no longer available. +/// +public sealed class DurableAgentResultUnavailableException : InvalidOperationException +{ + /// Initializes an empty instance. + public DurableAgentResultUnavailableException() + { + } + + /// Initializes an instance with a message. + public DurableAgentResultUnavailableException(string? message) + : base(message) + { + } + + /// Initializes an instance with a message and inner exception. + public DurableAgentResultUnavailableException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + internal DurableAgentResultUnavailableException( + string correlationId, + DateTimeOffset completedAt, + DateTimeOffset? resultExpiresAt, + string? outcome = null, + Exception? innerException = null) + : base($"Durable agent request '{correlationId}' completed, but its result payload is unavailable.", + DurableAgentFailure.CreateMetadataException(new DurableAgentFailureData + { + Version = 1, + CorrelationId = correlationId, + CompletedAt = completedAt, + ResultExpiresAt = resultExpiresAt, + Outcome = outcome, + }, innerException)) + { + this.CorrelationId = correlationId; + this.CompletedAt = completedAt; + this.ResultExpiresAt = resultExpiresAt; + this.Outcome = outcome; + } + + /// Gets the completed request correlation, when available. + public string? CorrelationId { get; } + + /// Gets when the request completed, when available. + public DateTimeOffset? CompletedAt { get; } + + /// Gets when the result payload expired, when configured. + public DateTimeOffset? ResultExpiresAt { get; } + + /// + /// Gets the original terminal outcome recorded by the completion receipt: + /// succeeded or failed. + /// + /// + /// Result unavailability describes payload retention, not execution success. A missing or + /// expired payload must not turn a recorded failure into success, or a recorded success into + /// an execution failure. Durable entity delivery and polling populate this property from + /// the authoritative receipt. It is for exceptions constructed without + /// receipt metadata using the general-purpose public constructors. + /// + public string? Outcome { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOutcome.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOutcome.cs new file mode 100644 index 0000000..eac0d65 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOutcome.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; + +namespace Microsoft.Agents.AI.DurableTask; + +internal enum DurableAgentRunOutcomeKind +{ + Pending, + Succeeded, + Failed, + CompletedResultUnavailable, +} + +internal sealed record DurableAgentRunOutcome( + DurableAgentRunOutcomeKind Kind, + AgentResponse? Response, + DurableAgentStateTerminalError? Error, + DurableAgentStateCompletionReceipt? Receipt) +{ + /// Gets the caller-visible JSON value; Undefined means absent, not explicit null. + public JsonElement Value { get; init; } + + public static DurableAgentRunOutcome Pending { get; } = + new(DurableAgentRunOutcomeKind.Pending, null, null, null); + + public static DurableAgentRunOutcome Succeeded( + AgentResponse response, + DurableAgentStateCompletionReceipt? receipt) => + new(DurableAgentRunOutcomeKind.Succeeded, response, null, receipt); + + public static DurableAgentRunOutcome Failed( + AgentResponse response, + DurableAgentStateTerminalError error, + DurableAgentStateCompletionReceipt? receipt) => + new(DurableAgentRunOutcomeKind.Failed, response, error, receipt); + + public static DurableAgentRunOutcome CompletedResultUnavailable( + DurableAgentStateCompletionReceipt receipt) => + new(DurableAgentRunOutcomeKind.CompletedResultUnavailable, null, null, receipt); + + internal AgentResponse GetResponse(string correlationId) => this.Kind switch + { + DurableAgentRunOutcomeKind.Succeeded => this.Response!, + DurableAgentRunOutcomeKind.Failed => throw new DurableAgentTerminalException( + correlationId, this.Error!.Code, this.Error.Message, this.Error.Details, this.Response!), + DurableAgentRunOutcomeKind.CompletedResultUnavailable => throw new DurableAgentResultUnavailableException( + correlationId, this.Receipt!.CompletedAt, this.Receipt.ResultExpiresAt, this.Receipt.Outcome), + _ => throw new InvalidOperationException($"Durable agent outcome '{this.Kind}' is not terminal."), + }; +} 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/DurableAgentStateCorruptionException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateCorruptionException.cs new file mode 100644 index 0000000..8e88d47 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateCorruptionException.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// The exception thrown when durable agent state violates a required storage invariant. +/// +public sealed class DurableAgentStateCorruptionException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + public DurableAgentStateCorruptionException() + { + } + + /// + /// Initializes a new instance with a specified error message. + /// + public DurableAgentStateCorruptionException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance with a specified error message and inner exception. + /// + public DurableAgentStateCorruptionException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance for duplicate terminal responses. + /// + public DurableAgentStateCorruptionException(string correlationId, int terminalResponseCount) + : base( + $"Durable agent state contains {terminalResponseCount} terminal responses for correlation " + + $"'{correlationId}'; at most one terminal response is allowed.") + { + this.CorrelationId = correlationId; + this.TerminalResponseCount = terminalResponseCount; + } + + /// + /// Gets the correlation ID whose invariant was violated, when available. + /// + public string? CorrelationId { get; } + + /// + /// Gets the number of terminal responses found, when available. + /// + public int? TerminalResponseCount { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateRetention.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateRetention.cs new file mode 100644 index 0000000..3ae2eb1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateRetention.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Applies deterministic pressure retention to durable agent state. +/// +internal static class DurableAgentStateRetention +{ + internal const double HighWatermark = 0.85; + internal const double LowWatermark = 0.70; + + public static int GetSerializedSize(DurableAgentState state) + { + return JsonSerializer.SerializeToUtf8Bytes( + state, + DurableAgentStateJsonContext.Default.DurableAgentState).Length; + } + + public static int Enforce( + DurableAgentState state, + DurableAgentHistoryRetentionMode mode, + int maxStateBytes, + DateTimeOffset now, + ILogger logger, + AgentSessionId sessionId) + { + if (mode == DurableAgentHistoryRetentionMode.KeepAll) + { + return 0; + } + + if (mode != DurableAgentHistoryRetentionMode.Auto) + { + throw new ArgumentOutOfRangeException( + nameof(mode), + mode, + "The durable agent history retention mode is not supported."); + } + + int highWatermark = (int)(maxStateBytes * HighWatermark); + int initialSize = GetSerializedSize(state); + if (initialSize < highWatermark) + { + DurableAgentTelemetry.RecordNoAction(sessionId.Name); + return 0; + } + + DurableAgentStateSchemaVersion schemaVersion = + DurableAgentStateSchemaVersion.ParseSupported(state.SchemaVersion); + if (schemaVersion.Major != DurableAgentState.RevisedSchemaMajorVersion) + { + throw new DurableAgentStateCorruptionException( + "Automatic history retention requires schema 2 mailbox state. Legacy terminal transcript " + + "entries must be converted to authoritative mailbox results before transcript eviction."); + } + + int lowWatermark = (int)(maxStateBytes * LowWatermark); + int removedEntries = 0; + int removedMessages = 0; + while (GetSerializedSize(state) > lowWatermark) + { + List? group = FindOldestEligibleExchange( + state.Data.ConversationHistory); + if (group is null) + { + break; + } + + int removedFromGroup = group.Sum(entry => entry.Messages.Count); + removedEntries += group.Count; + removedMessages += removedFromGroup; + foreach (DurableAgentStateEntry entry in group) + { + _ = state.Data.ConversationHistory.Remove(entry); + } + + RecordTruncation(state, removedFromGroup, now); + } + + int finalSize = GetSerializedSize(state); + bool protectedStateCapacityFailure = finalSize >= highWatermark; + RetentionResult result = new( + removedEntries, + removedMessages, + initialSize, + finalSize, + protectedStateCapacityFailure); + DurableAgentTelemetry.RecordRetentionAttempt(sessionId.Name, result); + + if (removedEntries > 0) + { + logger.LogDurableHistoryTruncated( + sessionId, + initialSize, + maxStateBytes, + removedEntries, + removedMessages, + finalSize); + } + + if (protectedStateCapacityFailure) + { + logger.LogDurableHistoryStillOverBudget( + sessionId, + finalSize, + maxStateBytes); + throw new DurableAgentStateSizeLimitExceededException(finalSize, maxStateBytes); + } + + return result.RemovedMessageCount; + } + + private static List? FindOldestEligibleExchange( + IList history) + { + List> groups = BuildAtomicGroups(history); + List? newestGroup = history.Count == 0 + ? null + : groups.First(group => group.Contains(history[^1])); + + foreach (List group in groups) + { + if (ReferenceEquals(group, newestGroup) || + group.Any(entry => entry.Messages.Any(message => message.Role == ChatRole.System.ToString()))) + { + continue; + } + + return group; + } + + return null; + } + + private static List> BuildAtomicGroups( + IList history) + { + int[] parents = Enumerable.Range(0, history.Count).ToArray(); + Dictionary correlationOwners = new(StringComparer.Ordinal); + Dictionary toolCallOwners = new(StringComparer.Ordinal); + + for (int index = 0; index < history.Count; index++) + { + DurableAgentStateEntry entry = history[index]; + if (entry.CorrelationId is not null) + { + UnionWithOwner(correlationOwners, entry.CorrelationId, index); + } + + HashSet entryToolCallIds = new(StringComparer.Ordinal); + foreach (DurableAgentStateContent content in entry.Messages.SelectMany(message => message.Contents)) + { + string? callId = content switch + { + DurableAgentStateFunctionCallContent functionCall => functionCall.CallId, + DurableAgentStateFunctionResultContent functionResult => functionResult.CallId, + _ => null, + }; + + if (!string.IsNullOrWhiteSpace(callId) && entryToolCallIds.Add(callId)) + { + UnionWithOwner(toolCallOwners, callId, index); + } + } + } + + Dictionary> components = []; + List roots = []; + for (int index = 0; index < history.Count; index++) + { + int root = Find(index); + if (!components.TryGetValue(root, out List? component)) + { + component = []; + components[root] = component; + roots.Add(root); + } + + component.Add(history[index]); + } + + return roots.ConvertAll(root => components[root]); + + void UnionWithOwner(Dictionary owners, string key, int index) + { + if (owners.TryGetValue(key, out int owner)) + { + Union(owner, index); + } + else + { + owners[key] = index; + } + } + + int Find(int index) + { + while (parents[index] != index) + { + parents[index] = parents[parents[index]]; + index = parents[index]; + } + + return index; + } + + void Union(int first, int second) + { + int firstRoot = Find(first); + int secondRoot = Find(second); + if (firstRoot == secondRoot) + { + return; + } + + if (firstRoot < secondRoot) + { + parents[secondRoot] = firstRoot; + } + else + { + parents[firstRoot] = secondRoot; + } + } + } + + private static void RecordTruncation( + DurableAgentState state, + int removedMessages, + DateTimeOffset now) + { + if (removedMessages == 0) + { + return; + } + + DurableAgentStateTruncation truncation = state.Data.Truncation ??= new() + { + FirstEvictedAt = now, + }; + + truncation.EvictedMessageCount += removedMessages; + truncation.LastEvictedAt = now; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateSizeLimitExceededException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateSizeLimitExceededException.cs new file mode 100644 index 0000000..4e8144c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateSizeLimitExceededException.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// The exception thrown when automatic retention cannot reduce durable agent state below its safe write threshold. +/// +public sealed class DurableAgentStateSizeLimitExceededException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + public DurableAgentStateSizeLimitExceededException() + { + } + + /// + /// Initializes a new instance with a specified error message. + /// + public DurableAgentStateSizeLimitExceededException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance with a specified error message and inner exception. + /// + public DurableAgentStateSizeLimitExceededException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The final serialized state size. + /// The configured state budget. + public DurableAgentStateSizeLimitExceededException(int stateSizeBytes, int maxStateBytes) + : base( + $"Durable agent state has a protected floor of {stateSizeBytes} bytes after all eligible transcript " + + $"eviction and cannot be safely persisted within the configured {maxStateBytes} byte budget.") + { + this.StateSizeBytes = stateSizeBytes; + this.MaxStateBytes = maxStateBytes; + } + + /// + /// Gets the final serialized state size. + /// + public int StateSizeBytes { get; } + + /// + /// Gets the configured state budget. + /// + public int MaxStateBytes { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentTelemetry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentTelemetry.cs new file mode 100644 index 0000000..c1c66e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentTelemetry.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Provides telemetry identifiers for durable agents. +/// +public static class DurableAgentTelemetry +{ + /// + /// Gets the name of the meter that emits durable-agent metrics. + /// + public const string MeterName = "Microsoft.Agents.AI.DurableTask"; + + internal const string EvictedMessagesInstrumentName = + "durable.agent.history.evicted.messages"; + internal const string EvictedEntriesInstrumentName = + "durable.agent.history.evicted.entries"; + internal const string ReclaimedBytesInstrumentName = + "durable.agent.history.reclaimed.bytes"; + internal const string StateSizeBeforeInstrumentName = + "durable.agent.history.state.size.before"; + internal const string StateSizeAfterInstrumentName = + "durable.agent.history.state.size.after"; + internal const string RetentionOperationsInstrumentName = + "durable.agent.history.retention.operations"; + + internal const string AgentNameTagName = "agent.name"; + internal const string OutcomeTagName = "outcome"; + internal const string ReasonTagName = "reason"; + + internal const string NoActionOutcome = "no_action"; + internal const string TranscriptEvictedOutcome = "transcript_evicted"; + internal const string ProtectedStateCapacityFailureOutcome = + "protected_state_capacity_failure"; + + internal const string TranscriptPressureReason = "transcript_pressure"; + + private static class Instruments + { + internal static readonly Meter Meter = new( + MeterName, + typeof(DurableAgentTelemetry).Assembly.GetName().Version?.ToString()); + internal static readonly Counter EvictedMessages = + Meter.CreateCounter( + EvictedMessagesInstrumentName, + unit: "{message}", + description: "Number of messages removed from durable agent history."); + internal static readonly Counter EvictedEntries = + Meter.CreateCounter( + EvictedEntriesInstrumentName, + unit: "{entry}", + description: "Number of entries removed from durable agent history."); + internal static readonly Counter ReclaimedBytes = + Meter.CreateCounter( + ReclaimedBytesInstrumentName, + unit: "By", + description: "Net serialized durable-state bytes reclaimed by history retention."); + internal static readonly Histogram StateSizeBefore = + Meter.CreateHistogram( + StateSizeBeforeInstrumentName, + unit: "By", + description: "Serialized durable-agent state size before a pressure-retention attempt."); + internal static readonly Histogram StateSizeAfter = + Meter.CreateHistogram( + StateSizeAfterInstrumentName, + unit: "By", + description: "Serialized durable-agent state size after a pressure-retention attempt."); + internal static readonly Counter RetentionOperations = + Meter.CreateCounter( + RetentionOperationsInstrumentName, + unit: "{operation}", + description: "Number of automatic durable-agent history retention checks by outcome."); + } + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Telemetry must never affect durable agent execution.")] + [SuppressMessage( + "Roslynator", + "RCS1075:Avoid empty catch clause that catches System.Exception", + Justification = "Telemetry must never affect durable agent execution.")] + internal static void RecordNoAction(string agentName) + { + try + { + Counter retentionOperations = Instruments.RetentionOperations; + if (!retentionOperations.Enabled) + { + return; + } + + TagList tags = default; + tags.Add(AgentNameTagName, agentName); + tags.Add(OutcomeTagName, NoActionOutcome); + retentionOperations.Add(1, tags); + } + catch (Exception) + { + // Metrics are best-effort operational telemetry. + } + } + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Telemetry must never affect durable agent execution.")] + [SuppressMessage( + "Roslynator", + "RCS1075:Avoid empty catch clause that catches System.Exception", + Justification = "Telemetry must never affect durable agent execution.")] + internal static void RecordRetentionAttempt( + string agentName, + RetentionResult result) + { + try + { + Counter evictedMessages = Instruments.EvictedMessages; + Counter evictedEntries = Instruments.EvictedEntries; + Counter reclaimedBytes = Instruments.ReclaimedBytes; + Histogram stateSizeBefore = Instruments.StateSizeBefore; + Histogram stateSizeAfter = Instruments.StateSizeAfter; + Counter retentionOperations = Instruments.RetentionOperations; + if (!evictedMessages.Enabled && + !evictedEntries.Enabled && + !reclaimedBytes.Enabled && + !stateSizeBefore.Enabled && + !stateSizeAfter.Enabled && + !retentionOperations.Enabled) + { + return; + } + + string outcome = result.Outcome switch + { + RetentionOutcome.TranscriptEvicted => TranscriptEvictedOutcome, + RetentionOutcome.ProtectedStateCapacityFailure => + ProtectedStateCapacityFailureOutcome, + _ => NoActionOutcome, + }; + + if (stateSizeBefore.Enabled || stateSizeAfter.Enabled) + { + TagList sizeTags = default; + sizeTags.Add(AgentNameTagName, agentName); + sizeTags.Add(OutcomeTagName, outcome); + stateSizeBefore.Record(result.InitialSizeBytes, sizeTags); + stateSizeAfter.Record(result.FinalSizeBytes, sizeTags); + } + + RecordEviction( + agentName, + TranscriptPressureReason, + result.RemovedEntryCount, + result.RemovedMessageCount, + Math.Max(0, result.InitialSizeBytes - result.FinalSizeBytes)); + + if (retentionOperations.Enabled) + { + TagList operationTags = default; + operationTags.Add(AgentNameTagName, agentName); + operationTags.Add(OutcomeTagName, outcome); + retentionOperations.Add(1, operationTags); + } + } + catch (Exception) + { + // Metrics are best-effort operational telemetry. + } + } + + private static void RecordEviction( + string agentName, + string reason, + int evictedEntries, + int evictedMessages, + int reclaimedBytes) + { + if (evictedEntries <= 0 && evictedMessages <= 0 && reclaimedBytes <= 0) + { + return; + } + + TagList tags = default; + tags.Add(AgentNameTagName, agentName); + tags.Add(ReasonTagName, reason); + if (evictedEntries > 0) + { + Instruments.EvictedEntries.Add(evictedEntries, tags); + } + + if (evictedMessages > 0) + { + Instruments.EvictedMessages.Add(evictedMessages, tags); + } + + if (reclaimedBytes > 0) + { + Instruments.ReclaimedBytes.Add(reclaimedBytes, tags); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentTerminalException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentTerminalException.cs new file mode 100644 index 0000000..4d40b59 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentTerminalException.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// The exception thrown when durable state records a terminal request failure. +/// +public sealed class DurableAgentTerminalException : InvalidOperationException +{ + /// Initializes an empty instance. + public DurableAgentTerminalException() + { + } + + /// Initializes an instance with a message. + public DurableAgentTerminalException(string? message) + : base(message) + { + } + + /// Initializes an instance with a message and inner exception. + public DurableAgentTerminalException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + internal DurableAgentTerminalException( + string correlationId, + string code, + string message, + JsonElement? details, + AgentResponse response, + Exception? innerException = null) + : base(message, DurableAgentFailure.CreateMetadataException(new DurableAgentFailureData + { + Version = 1, + CorrelationId = correlationId, + Code = code, + Details = details ?? default, + SerializedResponse = new DurableDataConverter().Serialize(response), + }, innerException)) + { + this.CorrelationId = correlationId; + this.Code = code; + this.Details = details is JsonElement { ValueKind: not JsonValueKind.Undefined } value ? value.Clone() : null; + this.Response = response; + } + + /// Gets the failed request correlation, when available. + public string? CorrelationId { get; } + + /// Gets the durable terminal error code, when available. + public string? Code { get; } + + /// Gets structured durable terminal error details, when available. + public JsonElement? Details { get; } + + /// Gets the recorded response payload, when available. + public AgentResponse? Response { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs index dda0a17..e0cdfca 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI.DurableTask.State; + namespace Microsoft.Agents.AI.DurableTask; /// @@ -10,6 +12,10 @@ 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 // this to decide whether an agent should get its own entry points: an agent that only exists because a @@ -27,7 +33,61 @@ internal DurableAgentsOptions() /// If an agent entity is idle for this duration, it will be automatically deleted. /// Defaults to 14 days. Set to to disable TTL for agents without explicit TTL configuration. /// - public TimeSpan? DefaultTimeToLive { get; set; } = TimeSpan.FromDays(14); + public TimeSpan? DefaultTimeToLive + { + get; + set + { + this._defaultTimeToLiveConfigured = true; + field = value; + } + } = TimeSpan.FromDays(14); + + /// + /// Gets or sets whether successful entity operations may publish schema 2.0 mailbox state. + /// Defaults to . + /// + /// + /// This internal switch supports execution tests only; it is not a production rollout API. + /// Public activation awaits shared contract, consumer/rollback, and late-duplicate policy agreement. + /// Readers support both layouts regardless of this setting. + /// + internal bool EnableMailboxWrites { get; set; } + + /// + /// Gets or sets the test-only agreement to delete receipt-bearing entities after an explicit TTL. + /// Production deletion remains disabled until a late-duplicate policy is agreed. + /// + internal bool EnableMailboxEntityDeletion { get; set; } + + /// + /// Gets or sets a trusted, per-state authorization for complete synthetic legacy migration fixtures. + /// + /// + /// No production authorization is installed. Retained transcript entries, an empty transcript, + /// or an absence of truncation metadata cannot prove that earlier completions were not evicted. + /// Known truncation/compaction prevents migration even when this callback authorizes the fixture. + /// + internal Func? AuthorizeLegacyMigration { get; set; } + + /// + /// Gets or sets optional retention for new mailbox result payloads. Defaults to no expiry. + /// Completion receipts remain until the whole entity is deleted. + /// + /// The retention period is not positive. + public TimeSpan? ResultRetentionPeriod + { + get; + set + { + if (value <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Result retention must be positive."); + } + + field = value; + } + } /// /// Gets or sets the minimum delay for scheduling TTL deletion signals. Defaults to 5 minutes. @@ -57,6 +117,124 @@ public TimeSpan MinimumTimeToLiveSignalDelay } } = TimeSpan.FromMinutes(5); + /// + /// Gets or sets how durable agent conversation state is retained. Defaults to + /// . + /// + /// + /// Selecting opts new sessions into + /// mailbox-aware schema 2 state because transcript eviction is safe only when completion + /// evidence is stored independently. Existing legacy sessions still require explicitly + /// authorized migration from independently authoritative complete history. + /// + public DurableAgentHistoryRetentionMode HistoryRetentionMode + { + get; + set => field = Enum.IsDefined(value) + ? value + : throw new ArgumentOutOfRangeException( + nameof(value), + value, + "The durable agent history retention mode is not supported."); + } = DurableAgentHistoryRetentionMode.KeepAll; + + /// + /// Gets or sets the extension-controlled serialized state budget used when + /// is . + /// Defaults to 1 MiB. + /// + /// + /// This budget measures the exact JSON payload produced by this extension. Durable Task backends can add + /// envelope bytes outside this payload, so the default retention watermarks intentionally leave headroom. + /// The budget is inactive in mode. + /// Automatic retention fails the operation if protected state cannot fit below the high watermark. + /// + public int MaxStateBytes + { + get; + set => field = value > 0 + ? value + : throw new ArgumentOutOfRangeException(nameof(value), value, "The durable agent state budget must be positive."); + } = 1_048_576; + + /// + /// 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. /// @@ -66,6 +244,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); @@ -92,6 +274,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) { @@ -102,6 +287,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) { @@ -168,10 +356,50 @@ internal IReadOnlyDictionary> GetAgentFa /// Gets the time-to-live for a specific agent, or the default TTL if not specified. /// /// The name of the agent. + /// Whether receipt-aware state requires an explicit deletion policy and TTL. /// The time-to-live for the agent, or the default TTL if not specified. - internal TimeSpan? GetTimeToLive(string agentName) + internal TimeSpan? GetTimeToLive(string agentName, bool revisedState = false) + { + if (revisedState && !this.EnableMailboxEntityDeletion) + { + return null; + } + + if (this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl)) + { + return ttl; + } + + // The legacy idle default must not silently delete completion evidence in revised state. + 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._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive; + return this._historyProviderKeys.TryGetValue(agentName, out string? providerKey) + ? providerKey + : null; } /// 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..78d713a --- /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 && + !string.IsNullOrWhiteSpace(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, + context.RequestMessages, + 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/DurableDataConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs index 08dddf6..59a201a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs @@ -17,6 +17,9 @@ namespace Microsoft.Agents.AI.DurableTask; /// internal sealed class DurableDataConverter : DataConverter { + private const string ResponseEnvelopeProperty = "$microsoftAgentFrameworkDurableTask"; + private const string ResponseEnvelopeKind = "agentResponse"; + private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -37,10 +40,19 @@ internal sealed class DurableDataConverter : DataConverter return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState); } + JsonElement? retainedResult = typeof(AgentResponse).IsAssignableFrom(targetType) + ? ReadRetainedResult(data) + : null; JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType); - return typeInfo is not null + object? deserialized = typeInfo is not null ? JsonSerializer.Deserialize(data, typeInfo) : JsonSerializer.Deserialize(data, targetType, s_options); + if (retainedResult is JsonElement result && deserialized is AgentResponse response) + { + DurableAgentJsonUtilities.CaptureRetainedResult(response, result); + } + + return deserialized; } [return: NotNullIfNotNull(nameof(value))] @@ -59,8 +71,78 @@ internal sealed class DurableDataConverter : DataConverter } JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType()); + if (value is AgentResponse response && + DurableAgentJsonUtilities.GetRetainedResult(response) is JsonElement result) + { + JsonElement native = typeInfo is not null + ? JsonSerializer.SerializeToElement(value, typeInfo) + : JsonSerializer.SerializeToElement(value, value.GetType(), s_options); + return WriteResponseEnvelope(native, result); + } + return typeInfo is not null ? JsonSerializer.Serialize(value, typeInfo) : JsonSerializer.Serialize(value, s_options); } + + private static string WriteResponseEnvelope(JsonElement nativeResponse, JsonElement result) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + foreach (JsonProperty property in nativeResponse.EnumerateObject()) + { + if (property.NameEquals(ResponseEnvelopeProperty)) + { + throw new JsonException("The native response conflicts with reserved durable response metadata."); + } + + property.WriteTo(writer); + } + + writer.WritePropertyName(ResponseEnvelopeProperty); + writer.WriteStartObject(); + writer.WriteString("kind", ResponseEnvelopeKind); + writer.WriteNumber("version", 1); + writer.WritePropertyName("result"); + result.WriteTo(writer); + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static JsonElement? ReadRetainedResult(string data) + { + using JsonDocument document = JsonDocument.Parse(data); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty(ResponseEnvelopeProperty, out JsonElement envelope)) + { + return null; + } + + if (root.EnumerateObject().Count(property => property.NameEquals(ResponseEnvelopeProperty)) != 1 || + envelope.ValueKind != JsonValueKind.Object || + envelope.EnumerateObject().Count(property => property.NameEquals("kind")) != 1 || + envelope.EnumerateObject().Count(property => property.NameEquals("version")) != 1 || + envelope.EnumerateObject().Count(property => property.NameEquals("result")) != 1 || + !envelope.TryGetProperty("kind", out JsonElement kind) || + kind.ValueKind != JsonValueKind.String || kind.GetString() != ResponseEnvelopeKind || + !envelope.TryGetProperty("version", out JsonElement version) || + version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out int versionNumber) || versionNumber != 1 || + !envelope.TryGetProperty("result", out JsonElement result) || result.ValueKind != JsonValueKind.Object || + !result.TryGetProperty("messages", out JsonElement messages) || messages.ValueKind != JsonValueKind.Array) + { + throw new JsonException("The durable response metadata envelope is malformed or unsupported."); + } + + DurableAgentStateTerminalResponse terminalResponse = result.Deserialize( + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResponse) + ?? throw new JsonException("The durable response result is missing."); + terminalResponse.Validate(); + return result.Clone(); + } } 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 2dd1e2a..de39633 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -101,6 +101,57 @@ public static partial void LogTTLExpirationTimeCleared( this ILogger logger, AgentSessionId sessionId); + [LoggerMessage( + EventId = 12, + Level = LogLevel.Warning, + Message = "[{SessionId}] Durable state reached {InitialSizeBytes} bytes of a {MaxStateBytes} byte budget. Retention evicted {EvictedEntryCount} transcript entries containing {EvictedMessageCount} message(s), leaving {FinalSizeBytes} bytes.")] + public static partial void LogDurableHistoryTruncated( + this ILogger logger, + AgentSessionId sessionId, + int initialSizeBytes, + int maxStateBytes, + int evictedEntryCount, + int evictedMessageCount, + int finalSizeBytes); + + [LoggerMessage( + EventId = 13, + Level = LogLevel.Error, + Message = "[{SessionId}] Durable state has a protected floor of {ProtectedStateSizeBytes} bytes against a {MaxStateBytes} byte budget after all eligible transcript eviction. Mailbox results, completion receipts, history binding, provider continuation, TTL, execution bookkeeping, system content, and the newest transcript exchange were not removed.")] + public static partial void LogDurableHistoryStillOverBudget( + this ILogger logger, + AgentSessionId sessionId, + int protectedStateSizeBytes, + int maxStateBytes); + + [LoggerMessage( + EventId = 14, + Level = LogLevel.Error, + 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, + Message = "[{SessionId}] Durable agent outcome state is corrupted for correlation ID '{CorrelationId}'.")] + public static partial void LogDurableOutcomeStateCorruption( + this ILogger logger, + Exception exception, + AgentSessionId sessionId, + string correlationId); + // Durable workflow logs (EventIds 100-199) [LoggerMessage( diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj index 34d852d..86e07cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj @@ -35,6 +35,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md index 44ac0fb..62b0eb6 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md @@ -37,6 +37,199 @@ You can alternatively just reference the `Microsoft.Agents.AI.Hosting.AzureFunct For a comprehensive tour of all the functionality, concepts, and APIs, check out the [.NET Durable Task samples](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples). +## Durable completion and delivery + +An invocation correlation ID is an idempotency key, not a transcript position. Do not reuse it for +different work. Legacy state resolves terminal responses and errors from the retained transcript. +Schema 2.0 resolves only the result mailbox and permanent completion receipts: pruning the transcript +does not make a completed correlation runnable again. A recorded completion with an expired or removed +payload is **completed but result unavailable**, not pending and not a new invocation. + +| Recorded state | Client behavior | +| --- | --- | +| No terminal evidence | Pending; a polling handle continues waiting | +| Successful completion with a result | Return the original response and its retained metadata | +| Supported committed terminal failure | Throw `DurableAgentTerminalException` with its code and details | +| Completion without an available payload | Throw `DurableAgentResultUnavailableException`, retaining whether the completion succeeded or failed | +| Inconsistent or unsupported state | Fail closed; do not invoke the model as a recovery fallback | + +These failure semantics also apply to direct orchestration calls and workflow agent executors. +A committed duplicate failure (including a legacy `errorResponse` and an empty retry) throws before +validation, agent construction, history/tool work, or migration; it cannot become downstream success. +The Durable Task SDK serializes exception type, message, and inner failures, but drops custom exception +properties. The entity therefore includes a versioned metadata snapshot in a +`DurableAgentFailureMetadataException` inner exception. `DurableAIAgent` restores the typed terminal or +unavailable exception from either `EntityOperationFailedException` or `TaskFailedException`, retaining +the SDK failure as a nested cause. Only these framework exception types select this contract; +model/user text is never used to infer outcomes. Unsupported metadata remains an SDK failure. +This is an additive failure-only transport change: `Run`/`RunAgentAsync` operation names, request and +successful-response wire formats, and entity-state schemas are unchanged. Older orchestration clients +still receive an SDK exception, not an ordinary successful response. Historical message-only failures +remain typed failures, without inventing metadata that was not recorded. + +One successful outer entity operation stages the immutable result and its receipt in an independent +working copy with the existing session/continuation, ingestion, entity transcript, whole-entity TTL, +binding, and other local state. Publishing that copy participates in the Durable Task entity commit. +Appending transcript text alone is not delivery acknowledgement. External history-provider writes and +tool effects are **not** part of this entity-local transaction; tool implementations still need their +own idempotency guarantees. + +Only a successfully committed outer invocation creates a new success receipt. Validation failures, +cancellation, model/provider errors, serialization/capacity failures, and failed entity commits remain +retryable; they are not converted into terminal receipts. Existing explicitly committed terminal +failure evidence can be read and migrated, but a transient exception does not establish such a contract. +Legacy conversion is evidence-only and idempotent. It never calls the model or tools and cannot +reconstruct receipts for results already evicted from legacy state. + +Completion receipts last until entity deletion. `DurableAgentsOptions.ResultRetentionPeriod` is optional +and defaults to no payload expiry. Result-payload retention and whole-entity TTL are separate policies; +there is no implicit 60-second mailbox expiry. Deleting the entity also deletes its +idempotency evidence. Keep schema 2.0 writes disabled until the shared rollout gates are agreed and every +participating reader/worker is mailbox-aware or explicitly rejects the new major version. +Producer activation and receipt-deleting entity TTL are internal test gates only, disabled by default. +Explicit `HistoryRetentionMode.Auto` is the narrow public opt-in that activates mailbox-aware schema 2 for +new sessions because transcript eviction cannot be safe without independent completion evidence. It does not +authorize migration of existing legacy sessions unless their complete history is independently authoritative. +Legacy TTL behavior is preserved, but old deadlines cannot delete schema-2 receipts without a separately agreed +deletion policy. Unknown-field +preservation by an older worker is not sufficient. See [state compatibility](State/README.md). + +`response.GetDurableResult()` returns the canonical retained terminal-response JSON for a durable +delivery, including optional `value` and unknown metadata that the native `AgentResponse` cannot +represent. An absent value remains absent, not explicit null. The registered `DurableDataConverter` +transports this JSON-only snapshot in additive namespaced response metadata, preserving native response +fields and plain legacy response reads. Direct native serialization does not preserve this association. +The metadata is result data, never a workflow control envelope or a runtime type selector. + +## Workflow output trust boundary + +Agent/model output and request-port responses are data, never workflow control envelopes. The framework +wraps their exact text in result-only values, including text that happens to be valid JSON or matches an +activity envelope. Only trusted regular activity/subworkflow results may supply state updates, scope +clears, events, routed messages, or halt requests. Invalid known activity-envelope fields fail closed to +plain result text without applying partial controls. Legacy plain-text activity results remain supported. + +This is a structural provenance boundary, not a signing/authenticity mechanism. No new discriminator or +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. +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. + +Pressure retention is opt-in. `KeepAll` is the default and performs no proactive history eviction; backend or +provider size limits can still reject a write. Select `Auto` and configure its positive serialized-state budget +when bounded transcript storage is preferred: + +```csharp +services.ConfigureDurableAgents(options => +{ + options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + options.MaxStateBytes = 1_048_576; + options.AddAIAgent(agent); +}); +``` + +`MaxStateBytes` is active only in `Auto`. The 85% high watermark starts a retention attempt, which removes the +oldest eligible transcript groups toward the 70% low watermark. The measured payload is the complete JSON state +produced by this extension, including terminal-result mailboxes, completion receipts, fixed history binding, +opaque provider or agent continuation, TTL, ingestion and workflow bookkeeping, truncation evidence, media, and +metadata. Durable Task backends can add envelope bytes outside this measurement. + +Selecting `Auto` activates mailbox-aware schema 2 writes for new sessions. Existing legacy sessions are migrated +only when the configured migration authorization confirms independently authoritative complete history; +otherwise the operation fails before model or provider side effects. + +Only `conversationHistory` transcript entries are eligible for pressure eviction. Mailbox result envelopes, +completion receipts, fixed history binding, serialized continuation, TTL, and other execution controls are +protected. Correlation IDs connect transcript request/response entries, and stable tool-call IDs connect calls +with results even across entries or correlations. Duplicate non-empty tool IDs are conservatively connected; +missing or empty IDs create no cross-entry edge. System-message groups and the newest transcript exchange are +also protected. + +Schema 2 mailbox results remain authoritative after their transcript copies are removed, so duplicate execution +and polling return the same retained result. Legacy state is converted to schema 2 before entity retention once +history ownership can be resolved. Retention itself fails closed if legacy transcript terminals are still the +only completion evidence. + +If all eligible transcript is removed and the protected floor still reaches the high watermark, +`DurableAgentStateSizeLimitExceededException` fails the operation without committing the working state. Auto +does not expire mailbox payloads; delivery expiry is a separate mailbox policy. Large inline image and +tool-result offload is not part of this implementation. + +Retention is separate from model-context compaction: retention destructively removes durable history only under +storage pressure, while compaction changes the context supplied to the model. `Auto` is not +`FollowCompaction`, and stateful compaction remains unsupported. + +### Retention metrics + +The package emits automatic-retention metrics through the +`Microsoft.Agents.AI.DurableTask` meter, with the package assembly version as its instrumentation scope version. +Applications can subscribe by using the public `DurableAgentTelemetry.MeterName` constant. The OpenTelemetry SDK +and exporter remain application choices; the product package depends only on `System.Diagnostics.Metrics`. + +| Instrument | Type | Unit | Tags | Meaning | +| --- | --- | --- | --- | --- | +| `durable.agent.history.evicted.entries` | Counter | `{entry}` | `agent.name`, `reason` | Transcript entries removed, including entries that contain no messages. | +| `durable.agent.history.evicted.messages` | Counter | `{message}` | `agent.name`, `reason` | Transcript messages removed. | +| `durable.agent.history.reclaimed.bytes` | Counter | `By` | `agent.name`, `reason` | Positive net serialized state bytes reclaimed by transcript eviction. | +| `durable.agent.history.state.size.before` | Histogram | `By` | `agent.name`, `outcome` | Exact serialized state size when an `Auto` check reaches the high watermark. | +| `durable.agent.history.state.size.after` | Histogram | `By` | `agent.name`, `outcome` | Exact serialized state size after that pressure-retention attempt. | +| `durable.agent.history.retention.operations` | Counter | `{operation}` | `agent.name`, `outcome` | Automatic retention checks by final outcome. | + +The bounded `outcome` values are `no_action`, `transcript_evicted`, and +`protected_state_capacity_failure`; the bounded `reason` value is `transcript_pressure`. +Removing a zero-message entry increments the entry counter without incrementing the message counter, and +reclaimed bytes are emitted only for a positive net reduction so truncation metadata never creates a negative +measurement. `KeepAll` emits no retention metrics. Session IDs, correlation IDs, message IDs, content, +exception text, and provider paths are never tags. + +These are **attempt-level operational metrics**, not durable-state truth. Retention is evaluated before the +entity operation commits, so a later scheduling, persistence, or retry failure can leave measurements for state +that was not committed; retries can also record an attempt more than once. Exporters can buffer or drop +telemetry. Reload persisted state and inspect model input or mailbox outcomes when validating committed behavior; +do not rely on emitted counters alone or exact-once metric delivery. + ## 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/RetentionResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/RetentionResult.cs new file mode 100644 index 0000000..4521663 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/RetentionResult.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +internal enum RetentionOutcome +{ + NoAction, + TranscriptEvicted, + ProtectedStateCapacityFailure, +} + +internal sealed record RetentionResult( + int RemovedEntryCount, + int RemovedMessageCount, + int InitialSizeBytes, + int FinalSizeBytes, + bool ProtectedStateCapacityFailure) +{ + public RetentionOutcome Outcome => this.ProtectedStateCapacityFailure + ? RetentionOutcome.ProtectedStateCapacityFailure + : this.RemovedEntryCount > 0 || this.FinalSizeBytes < this.InitialSizeBytes + ? RetentionOutcome.TranscriptEvicted + : RetentionOutcome.NoAction; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs index 0fc7ffc..31dc40b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs @@ -33,6 +33,11 @@ public record RunRequest /// /// Gets or sets the correlation ID for correlating this request with its response. /// + /// + /// This is a probabilistically unique caller idempotency key. Reusing it delivers the retained + /// terminal outcome (including throwing for a committed failure) without comparing request content. + /// Storage detects multiple terminal entries for one ID, but does not detect request-content collisions. + /// [JsonInclude] internal string CorrelationId { get; set; } = Guid.NewGuid().ToString("N"); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs index 35aef33..0f44cfd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Agents.AI.DurableTask.State; @@ -10,6 +11,12 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonConverter(typeof(DurableAgentStateJsonConverter))] internal sealed class DurableAgentState { + internal const string CurrentSchemaVersion = "1.2.0"; + internal const string RevisedSchemaVersion = "2.0.0"; + internal const int RevisedSchemaMajorVersion = 2; + private static readonly DurableAgentStateSchemaVersion s_currentSchemaVersion = + DurableAgentStateSchemaVersion.ParseSupported(CurrentSchemaVersion); + /// /// Gets the data of the durable agent. /// @@ -20,8 +27,57 @@ internal sealed class DurableAgentState /// Gets the schema version of the durable agent state. /// /// - /// The version is specified in semver (i.e. "major.minor.patch") format. + /// New states default to . Deserialization assigns the + /// persisted value through this init-only property, and constructs a new + /// state when an older declared version must be promoted for a legacy write. Only exact schema + /// snapshots reviewed by the shared contract are accepted; later versions fail closed. /// [JsonPropertyName("schemaVersion")] - public string SchemaVersion { get; init; } = "1.1.0"; + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + + // Not persisted: only mailbox-aware hydration or an explicitly enabled entity operation + // authorizes the production writer. Merely constructing a schema-2 DTO does not enable rollout. + [JsonIgnore] + internal bool MailboxWritesAuthorized { get; set; } + + /// + /// Gets application-defined root extension metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown root properties that are outside the declared schema. + /// + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + /// + /// Creates an independent copy suitable for an atomic entity operation. + /// + public DurableAgentState Clone() + { + string serialized = DurableAgentStateJsonConverter.SerializeRevisedContract(this); + DurableAgentState clone = DurableAgentStateJsonConverter.DeserializeRevisedContract(serialized); + DurableAgentStateMessageIdentity.EnsureMessageIds(clone.Data.ConversationHistory); + + return new DurableAgentState + { + SchemaVersion = SelectSchemaVersionForWrite(clone.SchemaVersion), + MailboxWritesAuthorized = this.MailboxWritesAuthorized, + Data = clone.Data, + ExtensionData = clone.ExtensionData, + UnknownProperties = clone.UnknownProperties, + }; + } + + private static string SelectSchemaVersionForWrite(string schemaVersion) + { + DurableAgentStateSchemaVersion sourceVersion = + DurableAgentStateSchemaVersion.ParseSupported(schemaVersion); + return sourceVersion.CompareTo(s_currentSchemaVersion) < 0 + ? CurrentSchemaVersion + : schemaVersion; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs new file mode 100644 index 0000000..578ae38 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a compacted transcript message written by another durable agent implementation. +/// +/// +/// This layer serializes, deserializes, and converts the shared compaction contract. Agent entity +/// replay and retention integration are deferred to later layers. +/// +internal sealed class DurableAgentStateCompaction : DurableAgentStateEntry; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs new file mode 100644 index 0000000..bfccea3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Immutable evidence that a correlation completed, retained independently from its result payload. +/// +internal sealed class DurableAgentStateCompletionReceipt +{ + public const string SucceededOutcome = "succeeded"; + public const string FailedOutcome = "failed"; + public const string AvailableResult = "available"; + public const string UnavailableResult = "unavailable"; + + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + [JsonPropertyName("outcome")] + public required string Outcome { get; init; } + + [JsonPropertyName("completedAt")] + public required DateTimeOffset CompletedAt { get; init; } + + [JsonPropertyName("resultState")] + public required string ResultState { get; init; } + + [JsonPropertyName("resultExpiresAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultExpiresAt { get; init; } + + [JsonPropertyName("resultUnavailableAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultUnavailableAt { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate(string dictionaryKey) + { + DurableAgentStateContract.ValidateIdentifier(dictionaryKey, "completionReceipts key"); + DurableAgentStateContract.ValidateIdentifier(this.CorrelationId, "completionReceipts.correlationId"); + if (!string.Equals(dictionaryKey, this.CorrelationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The durable agent state completion receipt key '{dictionaryKey}' does not match correlation ID '{this.CorrelationId}'."); + } + + if (this.Outcome is not SucceededOutcome and not FailedOutcome) + { + throw new InvalidOperationException( + $"The durable agent state completion outcome '{this.Outcome}' is not supported."); + } + + if (this.CompletedAt == default) + { + throw new InvalidOperationException( + "A durable agent completion receipt requires a completion timestamp."); + } + + if (this.ResultState is not AvailableResult and not UnavailableResult) + { + throw new InvalidOperationException( + $"The durable agent state result state '{this.ResultState}' is not supported."); + } + + if (this.ResultExpiresAt < this.CompletedAt) + { + throw new InvalidOperationException( + "The durable agent state result expiry cannot precede completion."); + } + + if (this.ResultState == AvailableResult && this.ResultUnavailableAt is not null) + { + throw new InvalidOperationException( + "An available durable agent result cannot have an unavailable timestamp."); + } + + if (this.ResultState == UnavailableResult && + (this.ResultUnavailableAt is null || this.ResultUnavailableAt < this.CompletedAt)) + { + throw new InvalidOperationException( + "An unavailable durable agent result requires an unavailable timestamp at or after completion."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs index 3ae7d12..ea79b9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -42,10 +43,10 @@ internal abstract class DurableAgentStateContent JsonSerializer.SerializeToElement(value: null, jsonTypeInfo: s_objectTypeInfo); /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets unknown content properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Converts this durable agent state content to an . @@ -53,18 +54,38 @@ internal abstract class DurableAgentStateContent /// A converted instance. public abstract AIContent ToAIContent(); + /// + /// Validates semantic constraints introduced by the schema 2.0 contract. + /// + public virtual void ValidateV2() + { + } + /// /// Creates a from an . /// /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original . - public static DurableAgentStateContent FromAIContent(AIContent content) + public static DurableAgentStateContent FromAIContent(AIContent content, ILogger? logger = null) + => FromAIContent(content, allowLosslessV2: false, logger); + + internal static DurableAgentStateContent FromAIContentV2(AIContent content, ILogger? logger = null) + => FromAIContent(content, allowLosslessV2: true, logger); + + private static DurableAgentStateContent FromAIContent( + AIContent content, + bool allowLosslessV2, + ILogger? logger) { return content switch { DataContent dataContent => DurableAgentStateDataContent.FromDataContent(dataContent), ErrorContent errorContent => DurableAgentStateErrorContent.FromErrorContent(errorContent), - FunctionCallContent functionCallContent => DurableAgentStateFunctionCallContent.FromFunctionCallContent(functionCallContent), + FunctionCallContent functionCallContent => + DurableAgentStateFunctionCallContent.FromFunctionCallContent( + functionCallContent, + allowLosslessV2), FunctionResultContent functionResultContent => DurableAgentStateFunctionResultContent.FromFunctionResultContent(functionResultContent), HostedFileContent hostedFileContent => DurableAgentStateHostedFileContent.FromHostedFileContent(hostedFileContent), HostedVectorStoreContent hostedVectorStoreContent => DurableAgentStateHostedVectorStoreContent.FromHostedVectorStoreContent(hostedVectorStoreContent), @@ -72,7 +93,7 @@ public static DurableAgentStateContent FromAIContent(AIContent content) TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent), UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent), UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent), - _ => DurableAgentStateUnknownContent.FromUnknownContent(content) + _ => DurableAgentStateUnknownContent.FromUnknownContent(content, logger) }; } @@ -95,7 +116,7 @@ protected static JsonElement ToJsonElement(object? value) return value switch { null => s_nullElement, - JsonElement element => element, + JsonElement element => element.Clone(), _ => JsonSerializer.SerializeToElement(value: value, jsonTypeInfo: s_objectTypeInfo) }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs new file mode 100644 index 0000000..7650198 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +internal static class DurableAgentStateContract +{ + public const int MaxIdentifierLength = 256; + public const int MaxMetadataKeyLength = 256; + public const int MaxMetadataStringLength = 16 * 1024; + + public static void ValidateIdentifier(string? value, string propertyName) + { + if (string.IsNullOrWhiteSpace(value) || + value.EnumerateRunes().Take(MaxIdentifierLength + 1).Count() > MaxIdentifierLength || + value.Any(char.IsControl)) + { + throw new InvalidOperationException( + $"The durable agent state '{propertyName}' property must be a non-empty string of at most {MaxIdentifierLength} characters without control characters."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index 745f619..7967f53 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -17,16 +17,221 @@ internal sealed class DurableAgentStateData [JsonPropertyName("conversationHistory")] public IList ConversationHistory { get; init; } = []; + /// + /// Gets immutable terminal result payloads indexed by correlation ID. + /// + [JsonPropertyName("terminalResults")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? TerminalResults { get; init; } + + /// + /// Gets completion receipts retained independently from result payload expiry. + /// + [JsonPropertyName("completionReceipts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? CompletionReceipts { get; init; } + + /// + /// Gets an optional, separately versioned runtime history profile. + /// + /// + /// The shared contract treats this object as opaque. This layer preserves its complete JSON shape + /// without interpreting owner fields, inferring defaults, or constraining per-run ownership transitions. + /// A relying C# profile may apply stricter validation in a later layer. + /// + [JsonPropertyName("historyBinding")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement HistoryBinding + { + get; + init + { + field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + } + + /// + /// Gets or sets the opaque state produced by the configured agent's session serialization contract. + /// + /// + /// This value can contain service conversation identity, continuation state, and provider-specific + /// state that cannot be reduced to a conversation ID. The durable state layer owns only the JSON + /// representation: it requires an object, clones assigned values away from caller-owned + /// instances, and round-trips the object without interpreting property + /// names such as $type or $runtimeType. It never uses this JSON to select or construct a + /// CLR type. A later integration layer may return the object only to the configured agent through + /// that agent's session deserialization contract. + /// + /// The normal System.Text.Json nesting limit applies when the enclosing state is parsed. + /// This schema layer intentionally has no independent byte cap because valid opaque provider state can + /// vary in size; the durable entity storage budget and retention policy remain the outer trust boundary. + /// Producers must therefore treat session state as persisted data, not as a trusted instruction or an + /// object graph. + /// + [JsonPropertyName("session")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Session + { + get; + set + { + if (value is not JsonElement element) + { + field = null; + return; + } + + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.session' property must be a JSON object."); + } + + field = element.Clone(); + } + } + + /// + /// Gets or sets the highest legacy scalar conversation position seen from each workflow producer. + /// + /// + /// This field records only the greatest observed position. It does not prove a contiguous delivered + /// prefix: after seeing positions 1 and 3, the scalar value 3 does not establish that position 2 was + /// delivered. It is distinct from the exact completion-receipt design used for terminal delivery. + /// The current .NET and Python production paths do not produce or consume these values; .NET preserves + /// and round-trips them so state written by a compatible workflow implementation is not discarded. + /// + [JsonPropertyName("ingestedPositions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? IngestedPositions { get; set; } + + /// + /// Gets or sets bounded evidence that transcript messages were removed from durable state. + /// + /// + /// The evidence persists after the corresponding transcript entries are gone and records the cumulative + /// count plus the first and latest eviction times. This lets readers and operators distinguish an + /// intentionally truncated transcript from one in which the missing messages were never persisted. + /// It is diagnostic provenance only: it is not model context, a terminal result, or proof that a + /// correlation completed. This layer preserves the contract but does not currently produce or consume + /// truncation evidence. + /// + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTruncation? Truncation { get; set; } + /// /// Gets or sets the expiration time (UTC) for this agent entity. /// If the entity is idle beyond this time, it will be automatically deleted. /// [JsonPropertyName("expirationTimeUtc")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? ExpirationTimeUtc { get; set; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets producer-defined values from the schema's declared data-level extensionData field. + /// + /// + /// This is an explicit interoperability field. It is separate from , + /// which captures undeclared future JSON members through . + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets undeclared future data properties that appear beside the schema's known fields. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } + + public void Validate(string schemaVersion) + { + DurableAgentStateSchemaVersion version = + DurableAgentStateSchemaVersion.ParseSupported(schemaVersion); + if (this.IngestedPositions?.Values.Any(static position => position < 0) == true) + { + throw new InvalidOperationException( + "Durable agent ingestion positions must be non-negative."); + } + + this.Truncation?.Validate(); + + if (version.Major == DurableAgentState.RevisedSchemaMajorVersion) + { + if (this.ConversationHistory is null) + { + throw new InvalidOperationException( + "A revised durable agent state requires a conversation history collection."); + } + + if (this.TerminalResults is null || + this.CompletionReceipts is null) + { + throw new InvalidOperationException( + "A revised durable agent state requires terminal results and completion receipts."); + } + + Dictionary terminalResults = + this.TerminalResults.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal); + Dictionary completionReceipts = + this.CompletionReceipts.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal); + + foreach (DurableAgentStateEntry? entry in this.ConversationHistory) + { + if (entry is null) + { + throw new InvalidOperationException( + "A revised durable agent state cannot contain null conversation entries."); + } + + entry.ValidateV2(); + } + + foreach ((string correlationId, DurableAgentStateTerminalResult result) in this.TerminalResults) + { + result.Validate(correlationId); + if (!completionReceipts.TryGetValue( + correlationId, + out DurableAgentStateCompletionReceipt? receipt)) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{correlationId}' has no completion receipt."); + } + + if (receipt.ResultState != DurableAgentStateCompletionReceipt.AvailableResult || + receipt.Outcome != result.Outcome || + receipt.CompletedAt != result.CompletedAt || + receipt.ResultExpiresAt != result.ResultExpiresAt) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{correlationId}' is inconsistent with its completion receipt."); + } + } + + foreach ((string correlationId, DurableAgentStateCompletionReceipt receipt) in this.CompletionReceipts) + { + receipt.Validate(correlationId); + bool hasResult = terminalResults.ContainsKey(correlationId); + if (receipt.ResultState == DurableAgentStateCompletionReceipt.AvailableResult != hasResult) + { + throw new InvalidOperationException( + $"Durable agent completion receipt '{correlationId}' is inconsistent with result availability."); + } + } + } + else if (this.TerminalResults is not null || + this.CompletionReceipts is not null || + this.HistoryBinding.ValueKind != JsonValueKind.Undefined) + { + throw new InvalidOperationException( + "Mailbox and provisional history-binding fields require durable agent state schema version 2.0.0."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs index 2f04c90..4db9af4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs @@ -12,6 +12,8 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(DurableAgentStateRequest), "request")] [JsonDerivedType(typeof(DurableAgentStateResponse), "response")] +[JsonDerivedType(typeof(DurableAgentStateErrorResponse), "errorResponse")] +[JsonDerivedType(typeof(DurableAgentStateCompaction), "compaction")] internal abstract class DurableAgentStateEntry { /// @@ -19,26 +21,68 @@ internal abstract class DurableAgentStateEntry /// /// /// This ID is used to correlate back to its - /// . + /// . Compaction entries do not have a correlation ID. /// [JsonPropertyName("correlationId")] - public required string CorrelationId { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CorrelationId { get; init; } /// /// Gets the timestamp when this entry was created. /// [JsonPropertyName("createdAt")] - public required DateTimeOffset CreatedAt { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } /// /// Gets the list of messages associated with this entry, in chronological order. /// [JsonPropertyName("messages")] - public IReadOnlyList Messages { get; init; } = []; + public IReadOnlyList Messages + { + get; + init => field = value ?? []; + } = []; /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets application-defined entry metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown entry properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } + + public void ValidateV2() + { + if (this is DurableAgentStateCompaction) + { + if (this.CorrelationId is not null) + { + throw new InvalidOperationException( + "A durable agent compaction entry cannot have a correlation ID."); + } + } + else if (this.CorrelationId is not null) + { + DurableAgentStateContract.ValidateIdentifier( + this.CorrelationId, + "conversationHistory.correlationId"); + } + + foreach (DurableAgentStateMessage? message in this.Messages) + { + if (message is null) + { + throw new InvalidOperationException( + "A revised durable agent state cannot contain null messages."); + } + + message.ValidateV2(); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs index 17e5fea..b73a603 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -28,8 +29,12 @@ internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent /// Gets the error details. /// [JsonPropertyName("details")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Details { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Details + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } /// /// Creates a from an . @@ -41,7 +46,11 @@ public static DurableAgentStateErrorContent FromErrorContent(ErrorContent conten { return new DurableAgentStateErrorContent() { - Details = content.Details, + Details = content.Details is null + ? default + : JsonSerializer.SerializeToElement( + content.Details, + DurableAgentStateJsonContext.Default.String), ErrorCode = content.ErrorCode, Message = content.Message }; @@ -52,7 +61,12 @@ public override AIContent ToAIContent() { return new ErrorContent(this.Message) { - Details = this.Details, + Details = this.Details.ValueKind switch + { + JsonValueKind.Undefined => null, + JsonValueKind.String => this.Details.GetString(), + _ => this.Details.GetRawText(), + }, ErrorCode = this.ErrorCode }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs new file mode 100644 index 0000000..bb5742c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a failed turn recorded by another durable agent implementation. +/// +/// +/// .NET durable agents do not currently create pollable error responses, but preserve this shared-schema +/// entry kind when reading state written by another language implementation. +/// +internal sealed class DurableAgentStateErrorResponse : DurableAgentStateResponse; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs index 8b655e1..5ade09c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -13,19 +12,14 @@ namespace Microsoft.Agents.AI.DurableTask.State; internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent { /// - /// The function call arguments, each encoded as JSON. + /// Gets the original function-call arguments as an object or verbatim string. /// /// - /// Arguments produced by a chat client from a model response are already - /// values, but callers can supply containing arbitrary objects (for - /// example when replaying history or resuming an approval). Those are encoded here using - /// so that persisting the state cannot fail on a type the - /// state serializer has no metadata for. + /// String form is preserved without parsing or normalization, including incomplete or non-JSON text. /// - /// TODO: Consider ensuring that empty dictionaries are omitted from serialization. [JsonPropertyName("arguments")] - public required IReadOnlyDictionary Arguments { get; init; } = - ImmutableDictionary.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Arguments { get; init; } /// /// Gets the function call identifier. @@ -47,18 +41,32 @@ internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateCo /// Creates a from a . /// /// The to convert. + /// Whether v2-only verbatim string arguments may be persisted. /// /// A representing the original content. /// - public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content) + public static DurableAgentStateFunctionCallContent FromFunctionCallContent( + FunctionCallContent content, + bool allowLosslessV2 = false) { - Dictionary arguments = []; - if (content.Arguments is not null) + JsonElement arguments = default; + if (allowLosslessV2 && content.RawRepresentation is string encodedArguments) { + arguments = JsonSerializer.SerializeToElement( + encodedArguments, + DurableAgentStateJsonContext.Default.String); + } + else if (content.Arguments is not null) + { + Dictionary argumentValues = []; foreach (KeyValuePair argument in content.Arguments) { - arguments[argument.Key] = ToJsonElement(argument.Value); + argumentValues[argument.Key] = ToJsonElement(argument.Value); } + + arguments = JsonSerializer.SerializeToElement( + argumentValues, + DurableAgentStateJsonContext.Default.DictionaryStringJsonElement); } return new DurableAgentStateFunctionCallContent() @@ -72,12 +80,38 @@ public static DurableAgentStateFunctionCallContent FromFunctionCallContent(Funct /// public override AIContent ToAIContent() { - Dictionary arguments = new(this.Arguments.Count); - foreach (KeyValuePair argument in this.Arguments) + if (this.Arguments.ValueKind == JsonValueKind.String) { - arguments[argument.Key] = argument.Value; + string encodedArguments = this.Arguments.GetString()!; + return new FunctionCallContent(this.CallId, this.Name) + { + RawRepresentation = encodedArguments, + }; + } + + Dictionary? arguments = + this.Arguments.ValueKind == JsonValueKind.Undefined ? [] : null; + if (this.Arguments.ValueKind == JsonValueKind.Object) + { + arguments = []; + foreach (JsonProperty argument in this.Arguments.EnumerateObject()) + { + arguments[argument.Name] = argument.Value.Clone(); + } } return new FunctionCallContent(this.CallId, this.Name, arguments); } + + /// + public override void ValidateV2() + { + if (this.Arguments.ValueKind is not JsonValueKind.Undefined and + not JsonValueKind.Object and + not JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent function-call arguments must be an object, a verbatim string, or absent."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs index 8c79d67..b5a1af8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs @@ -32,8 +32,12 @@ internal sealed class DurableAgentStateFunctionResultContent : DurableAgentState /// persisted under this single property. /// [JsonPropertyName("result")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Result { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Result + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } /// /// Creates a from a . @@ -48,15 +52,14 @@ public static DurableAgentStateFunctionResultContent FromFunctionResultContent(F // A null result is left absent rather than encoded as a JSON null so that it round trips // back to a null FunctionResultContent.Result. - Result = content.Result is null ? null : ToJsonElement(content.Result) + Result = content.Result is null ? default : ToJsonElement(content.Result) }; } /// public override AIContent ToAIContent() { - // Boxing a JsonElement? yields either a boxed JsonElement or null, matching the shape chat - // clients expect from a tool whose result was marshalled into JSON. - return new FunctionResultContent(this.CallId, this.Result); + object? result = this.Result.ValueKind == JsonValueKind.Undefined ? null : this.Result; + return new FunctionResultContent(this.CallId, result); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index 4ad9a62..edfb2ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -11,10 +11,19 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(DurableAgentStateContent))] [JsonSerializable(typeof(DurableAgentStateData))] [JsonSerializable(typeof(DurableAgentStateEntry))] +[JsonSerializable(typeof(DurableAgentStateErrorResponse))] +[JsonSerializable(typeof(DurableAgentStateCompaction))] [JsonSerializable(typeof(DurableAgentStateMessage))] +[JsonSerializable(typeof(DurableAgentStateTruncation))] +[JsonSerializable(typeof(DurableAgentStateCompletionReceipt))] +[JsonSerializable(typeof(DurableAgentStateTerminalResult))] +[JsonSerializable(typeof(DurableAgentStateTerminalResponse))] +[JsonSerializable(typeof(DurableAgentStateTerminalError))] // Function call and result content [JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(object))] [JsonSerializable(typeof(JsonDocument))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonNode))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index 4c7796b..062477f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace Microsoft.Agents.AI.DurableTask.State; @@ -10,8 +12,13 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// internal sealed class DurableAgentStateJsonConverter : JsonConverter { + private static readonly Regex s_rfc3339Pattern = new( + @"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$", + RegexOptions.CultureInvariant); + private const string SchemaVersionPropertyName = "schemaVersion"; private const string DataPropertyName = "data"; + private const string ExtensionDataPropertyName = "extensionData"; /// public override DurableAgentState? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -20,6 +27,37 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter? extensionData = + element.Value.TryGetProperty(ExtensionDataPropertyName, out JsonElement extensionDataElement) + ? ReadExtensionData(extensionDataElement) + : null; + Dictionary? unknownProperties = null; + foreach (JsonProperty property in element.Value.EnumerateObject()) + { + if (property.NameEquals(SchemaVersionPropertyName) || + property.NameEquals(DataPropertyName) || + property.NameEquals(ExtensionDataPropertyName)) + { + continue; + } + + unknownProperties ??= []; + unknownProperties[property.Name] = property.Value.Clone(); + } return new DurableAgentState { - SchemaVersion = schemaVersion.ToString(), - Data = data ?? new DurableAgentStateData() + SchemaVersion = schemaVersionText!, + Data = data, + ExtensionData = extensionData, + UnknownProperties = unknownProperties, }; } /// public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options) { + WriteValue(writer, value, allowRevisedSchema: value.MailboxWritesAuthorized); + } + + private static void WriteValue( + Utf8JsonWriter writer, + DurableAgentState value, + bool allowRevisedSchema) + { + _ = DurableAgentStateSchemaVersion.ParseSupported(value.SchemaVersion); + if (value.SchemaVersion == DurableAgentState.RevisedSchemaVersion && !allowRevisedSchema) + { + throw new InvalidOperationException( + "Durable agent state schema 2.0.0 requires mailbox-aware runtime activation."); + } + + value.Data.Validate(value.SchemaVersion); + + JsonElement data = JsonSerializer.SerializeToElement( + value.Data, DurableAgentStateJsonContext.Default.DurableAgentStateData); + if (value.SchemaVersion != DurableAgentState.RevisedSchemaVersion) + { + // Apply the historical reader's shape checks before publishing any legacy JSON. + // DTOs and extension properties must not bypass the legacy message adapters. + RejectLegacyRevisedFields(data); + ValidateLegacyTranscript(data); + } + writer.WriteStartObject(); writer.WritePropertyName(SchemaVersionPropertyName); writer.WriteStringValue(value.SchemaVersion); writer.WritePropertyName(DataPropertyName); - JsonSerializer.Serialize( - writer, - value.Data, - DurableAgentStateJsonContext.Default.DurableAgentStateData); + data.WriteTo(writer); + if (value.ExtensionData is not null) + { + writer.WritePropertyName(ExtensionDataPropertyName); + WriteExtensionData(writer, value.ExtensionData); + } + + if (value.UnknownProperties is not null) + { + foreach ((string propertyName, JsonElement propertyValue) in value.UnknownProperties) + { + if (propertyName is not SchemaVersionPropertyName and + not DataPropertyName and + not ExtensionDataPropertyName) + { + writer.WritePropertyName(propertyName); + propertyValue.WriteTo(writer); + } + } + } + writer.WriteEndObject(); } + + private static Dictionary? ReadExtensionData(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The durable agent state 'extensionData' property must be an object."); + } + + return element.EnumerateObject().ToDictionary( + property => property.Name, + property => property.Value.Clone()); + } + + private static void WriteExtensionData( + Utf8JsonWriter writer, + IDictionary extensionData) + { + writer.WriteStartObject(); + foreach ((string propertyName, JsonElement propertyValue) in extensionData) + { + writer.WritePropertyName(propertyName); + propertyValue.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + private static void ValidateRevisedLayout(JsonElement dataElement) + { + if (dataElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The revised durable agent state 'data' property must be an object."); + } + + foreach (string requiredProperty in new[] + { + "conversationHistory", + "terminalResults", + "completionReceipts", + }) + { + if (!dataElement.TryGetProperty(requiredProperty, out _)) + { + throw new InvalidOperationException( + $"The revised durable agent state is missing the 'data.{requiredProperty}' property."); + } + } + + ValidateUniqueObjectKeys(dataElement.GetProperty("terminalResults"), "terminalResults"); + ValidateUniqueObjectKeys(dataElement.GetProperty("completionReceipts"), "completionReceipts"); + ValidateIngestionAndTruncation(dataElement); + ValidateTranscript(dataElement.GetProperty("conversationHistory")); + ValidateTerminalMessages(dataElement.GetProperty("terminalResults")); + } + + private static void ValidateOpaqueSession(JsonElement dataElement) + { + if (dataElement.ValueKind == JsonValueKind.Object && + dataElement.TryGetProperty("session", out JsonElement session) && + session.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.session' property must be a JSON object."); + } + } + + private static void RejectLegacyRevisedFields(JsonElement dataElement) + { + if (dataElement.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (string propertyName in new[] + { + "terminalResults", + "completionReceipts", + "historyBinding", + }) + { + if (dataElement.TryGetProperty(propertyName, out _)) + { + throw new InvalidOperationException( + $"The durable agent state 'data.{propertyName}' property requires schema version 2.0.0."); + } + } + + ValidateIngestionAndTruncation(dataElement); + } + + private static void ValidateLegacyTranscript(JsonElement dataElement) + { + if (!dataElement.TryGetProperty("conversationHistory", out JsonElement history)) + { + return; + } + + if (history.ValueKind != JsonValueKind.Array) + { + throw new JsonException( + "The legacy durable agent state 'data.conversationHistory' property must be an array."); + } + + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent conversation history cannot contain non-object entries."); + } + + if (!entry.TryGetProperty("messages", out JsonElement messages)) + { + continue; + } + + if (messages.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "Legacy durable agent entry messages must be an array when present."); + } + + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent entry messages cannot contain non-object values."); + } + + string? roleText = + message.TryGetProperty("role", out JsonElement role) && + role.ValueKind == JsonValueKind.String + ? role.GetString() + : null; + if (roleText is not ("user" or "assistant" or "system" or "tool")) + { + throw new InvalidOperationException( + $"The legacy durable agent state message role '{roleText}' is not supported."); + } + + if (!message.TryGetProperty("contents", out JsonElement contents)) + { + continue; + } + + if (contents.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "Legacy durable agent message contents must be an array when present."); + } + + foreach (JsonElement content in contents.EnumerateArray()) + { + if (content.ValueKind != JsonValueKind.Object || + !content.TryGetProperty("$type", out JsonElement contentType) || + contentType.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "Legacy durable agent message contents require object values with string discriminators."); + } + + if (contentType.ValueEquals("functionCall") && + content.TryGetProperty("arguments", out JsonElement arguments) && + arguments.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent function-call arguments must be an object when present."); + } + + if (contentType.ValueEquals("uri") && + (!content.TryGetProperty("mediaType", out JsonElement mediaType) || + mediaType.ValueKind != JsonValueKind.String)) + { + throw new InvalidOperationException( + "Legacy durable agent URI content requires a string mediaType."); + } + + if (contentType.ValueEquals("usage") && + content.TryGetProperty("usage", out JsonElement contentUsage)) + { + if (contentUsage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent usage content requires an object-valued usage property."); + } + + ValidateUsageObject(contentUsage, "message.contents.usage"); + } + + ValidateKnownContentFields(content, contentType.GetString()!); + } + } + } + } + + private static void ValidateIngestionAndTruncation(JsonElement dataElement) + { + if (dataElement.TryGetProperty("ingestedPositions", out JsonElement ingestedPositions)) + { + if (ingestedPositions.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.ingestedPositions' property must be an object."); + } + + foreach (JsonProperty position in ingestedPositions.EnumerateObject()) + { + if (position.Value.ValueKind != JsonValueKind.Number || + !position.Value.TryGetInt32(out int value) || + value < 0) + { + throw new InvalidOperationException( + $"The durable agent ingestion position '{position.Name}' must be a non-negative Int32 value."); + } + } + } + + if (dataElement.TryGetProperty("truncation", out JsonElement truncation)) + { + if (truncation.ValueKind != JsonValueKind.Object || + !truncation.TryGetProperty("evictedMessageCount", out _) || + !truncation.TryGetProperty("firstEvictedAt", out _) || + !truncation.TryGetProperty("lastEvictedAt", out _)) + { + throw new InvalidOperationException( + "Durable agent truncation evidence requires evictedMessageCount, firstEvictedAt, and lastEvictedAt."); + } + } + } + + private static void ValidateDeclaredExtensionData(JsonElement root, JsonElement data) + { + RequireObjectWhenPresent(root, ExtensionDataPropertyName, "extensionData"); + RequireObjectWhenPresent(data, ExtensionDataPropertyName, "data.extensionData"); + + if (data.TryGetProperty("conversationHistory", out JsonElement history) && + history.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireObjectWhenPresent(entry, ExtensionDataPropertyName, "conversationHistory.extensionData"); + string? entryType = entry.TryGetProperty("$type", out JsonElement typeElement) && + typeElement.ValueKind == JsonValueKind.String + ? typeElement.GetString() + : null; + if (entryType is "response" or "errorResponse" && + entry.TryGetProperty("usage", out JsonElement usage) && + usage.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent(usage, ExtensionDataPropertyName, "conversationHistory.usage.extensionData"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages) && + messages.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + message, + ExtensionDataPropertyName, + "conversationHistory.messages.extensionData"); + } + } + } + } + } + + if (data.TryGetProperty("terminalResults", out JsonElement terminalResults) && + terminalResults.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (result.Value.TryGetProperty("resultExpiresAt", out JsonElement resultExpiresAt) && + resultExpiresAt.ValueKind != JsonValueKind.String) + { + throw new JsonException( + $"Durable agent terminal result '{result.Name}' resultExpiresAt must be a string when present."); + } + + if (result.Value.TryGetProperty("error", out JsonElement error) && + error.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + $"Durable agent terminal result '{result.Name}' error must be an object when present."); + } + + if (result.Value.TryGetProperty("response", out JsonElement response) && + response.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + response, + ExtensionDataPropertyName, + $"terminalResults.{result.Name}.response.extensionData"); + foreach (string propertyName in new[] + { + "createdAt", + "responseId", + "agentId", + "finishReason", + "continuationToken", + }) + { + RequireStringWhenPresent( + response, + propertyName, + $"terminalResults.{result.Name}.response.{propertyName}"); + } + + if (response.TryGetProperty("usage", out JsonElement usage) && + usage.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + usage, + ExtensionDataPropertyName, + $"terminalResults.{result.Name}.response.usage.extensionData"); + } + } + } + } + + if (data.TryGetProperty("completionReceipts", out JsonElement receipts) && + receipts.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty receipt in receipts.EnumerateObject()) + { + foreach (string propertyName in new[] { "resultExpiresAt", "resultUnavailableAt" }) + { + RequireStringWhenPresent( + receipt.Value, + propertyName, + $"completionReceipts.{receipt.Name}.{propertyName}"); + } + } + } + } + + private static void ValidateKnownFieldShapes(JsonElement data) + { + RequireDateTimeWhenPresent( + data, + "expirationTimeUtc", + "data.expirationTimeUtc", + allowNull: true); + + if (data.TryGetProperty("conversationHistory", out JsonElement history) && + history.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireDateTimeWhenPresent(entry, "createdAt", "conversationHistory.createdAt"); + RequireStringWhenPresent(entry, "correlationId", "conversationHistory.correlationId"); + string? entryType = entry.TryGetProperty("$type", out JsonElement typeElement) && + typeElement.ValueKind == JsonValueKind.String + ? typeElement.GetString() + : null; + if (entryType == "request") + { + RequireStringWhenPresent(entry, "orchestrationId", "conversationHistory.orchestrationId"); + RequireStringWhenPresent(entry, "responseType", "conversationHistory.responseType"); + RequireObjectWhenPresent(entry, "responseSchema", "conversationHistory.responseSchema"); + } + else if (entryType is "response" or "errorResponse") + { + ValidateUsageWhenPresent(entry, "usage", "conversationHistory.usage"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages) && + messages.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireStringWhenPresent(message, "authorName", "conversationHistory.messages.authorName"); + RequireDateTimeWhenPresent( + message, + "createdAt", + "conversationHistory.messages.createdAt"); + RequireStringWhenPresent(message, "messageId", "conversationHistory.messages.messageId"); + } + } + } + } + + if (data.TryGetProperty("terminalResults", out JsonElement terminalResults) && + terminalResults.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (result.Value.TryGetProperty("response", out JsonElement response) && + response.ValueKind == JsonValueKind.Object) + { + RequireDateTimeWhenPresent( + result.Value, + "completedAt", + $"terminalResults.{result.Name}.completedAt"); + RequireDateTimeWhenPresent( + result.Value, + "resultExpiresAt", + $"terminalResults.{result.Name}.resultExpiresAt"); + RequireDateTimeWhenPresent( + response, + "createdAt", + $"terminalResults.{result.Name}.response.createdAt"); + ValidateUsageWhenPresent( + response, + "usage", + $"terminalResults.{result.Name}.response.usage"); + } + } + } + + if (data.TryGetProperty("completionReceipts", out JsonElement receipts) && + receipts.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty receipt in receipts.EnumerateObject()) + { + RequireDateTimeWhenPresent( + receipt.Value, + "completedAt", + $"completionReceipts.{receipt.Name}.completedAt"); + RequireDateTimeWhenPresent( + receipt.Value, + "resultExpiresAt", + $"completionReceipts.{receipt.Name}.resultExpiresAt"); + RequireDateTimeWhenPresent( + receipt.Value, + "resultUnavailableAt", + $"completionReceipts.{receipt.Name}.resultUnavailableAt"); + } + } + + if (data.TryGetProperty("truncation", out JsonElement truncation) && + truncation.ValueKind == JsonValueKind.Object) + { + RequireDateTimeWhenPresent(truncation, "firstEvictedAt", "data.truncation.firstEvictedAt"); + RequireDateTimeWhenPresent(truncation, "lastEvictedAt", "data.truncation.lastEvictedAt"); + } + } + + private static void ValidateUsageWhenPresent(JsonElement parent, string propertyName, string path) + { + if (!parent.TryGetProperty(propertyName, out JsonElement usage)) + { + return; + } + + if (usage.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The durable agent state '{path}' property must be an object."); + } + + ValidateUsageObject(usage, path); + } + + private static void ValidateUsageObject(JsonElement usage, string path) + { + foreach (string countName in new[] { "inputTokenCount", "outputTokenCount", "totalTokenCount" }) + { + if (usage.TryGetProperty(countName, out JsonElement count) && + (count.ValueKind != JsonValueKind.Number || !count.TryGetInt64(out _))) + { + throw new JsonException( + $"The durable agent state '{path}.{countName}' property must be an Int64 value."); + } + } + + RequireObjectWhenPresent(usage, ExtensionDataPropertyName, $"{path}.extensionData"); + } + + private static void RequireObjectWhenPresent(JsonElement parent, string propertyName, string path) + { + if (parent.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The durable agent state '{path}' property must be an object."); + } + } + + private static void RequireStringWhenPresent(JsonElement parent, string propertyName, string path) + { + if (parent.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.String) + { + throw new JsonException($"The durable agent state '{path}' property must be a string."); + } + } + + private static void RequireDateTimeWhenPresent( + JsonElement parent, + string propertyName, + string path, + bool allowNull = false) + { + if (!parent.TryGetProperty(propertyName, out JsonElement value)) + { + return; + } + + if (allowNull && value.ValueKind == JsonValueKind.Null) + { + return; + } + + if (value.ValueKind != JsonValueKind.String || + !IsOffsetRfc3339(value.GetString())) + { + throw new JsonException( + $"The durable agent state '{path}' property must be an RFC 3339 date-time with an explicit offset."); + } + } + + private static bool IsOffsetRfc3339(string? value) + { + if (string.IsNullOrEmpty(value) || + !s_rfc3339Pattern.IsMatch(value) || + !DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _)) + { + return false; + } + + return value.EndsWith('Z') || + (value.Length >= 6 && + value[^6] is '+' or '-' && + value[^3] == ':'); + } + + private static void ValidateUniqueObjectKeys(JsonElement element, string propertyName) + { + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The revised durable agent state 'data.{propertyName}' property must be an object."); + } + + HashSet keys = new(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!keys.Add(property.Name)) + { + throw new InvalidOperationException( + $"The revised durable agent state 'data.{propertyName}' property contains duplicate correlation ID '{property.Name}'."); + } + } + } + + private static void ValidateTerminalMessages(JsonElement terminalResults) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (!result.Value.TryGetProperty("response", out JsonElement response) || + !response.TryGetProperty("messages", out JsonElement messages)) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{result.Name}' requires a response messages collection."); + } + + ValidateMessageArray(messages, $"terminal result '{result.Name}'"); + } + } + + private static void ValidateTranscript(JsonElement conversationHistory) + { + if (conversationHistory.ValueKind != JsonValueKind.Array) + { + throw new JsonException( + "The revised durable agent state 'data.conversationHistory' property must be an array."); + } + + foreach (JsonElement entry in conversationHistory.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object || + !entry.TryGetProperty("$type", out JsonElement typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + continue; + } + + string? entryType = typeElement.GetString(); + bool hasCorrelation = entry.TryGetProperty("correlationId", out JsonElement correlation); + if (entryType == "compaction" && hasCorrelation) + { + throw new InvalidOperationException( + "A revised durable agent compaction entry cannot declare correlationId."); + } + + if (entryType is "request" or "response" or "errorResponse" && hasCorrelation) + { + if (correlation.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "A revised durable agent transcript correlationId must be a string when present."); + } + + DurableAgentStateContract.ValidateIdentifier( + correlation.GetString(), + "conversationHistory.correlationId"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages)) + { + ValidateMessageArray(messages, "conversationHistory"); + } + } + } + + private static void ValidateMessageArray(JsonElement messages, string location) + { + if (messages.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent {location} messages must be an array."); + } + + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Durable agent {location} contains a non-object message."); + } + + RequireStringWhenPresent(message, "authorName", $"{location}.authorName"); + RequireDateTimeWhenPresent(message, "createdAt", $"{location}.createdAt"); + RequireStringWhenPresent(message, "messageId", $"{location}.messageId"); + RequireObjectWhenPresent(message, ExtensionDataPropertyName, $"{location}.extensionData"); + + if (!message.TryGetProperty("contents", out JsonElement contents)) + { + continue; + } + + if (contents.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent {location} contains a non-array message contents property."); + } + + foreach (JsonElement content in contents.EnumerateArray()) + { + if (content.ValueKind != JsonValueKind.Object || + !content.TryGetProperty("$type", out JsonElement contentType) || + contentType.ValueKind != JsonValueKind.String) + { + continue; + } + + if (contentType.ValueEquals("functionCall") && + content.TryGetProperty("arguments", out JsonElement arguments) && + arguments.ValueKind is not JsonValueKind.Object and not JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent function-call arguments must be an object or string when present."); + } + + if (contentType.ValueEquals("uri") && + content.TryGetProperty("mediaType", out JsonElement mediaType) && + mediaType.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent URI mediaType must be a string when present."); + } + + if (contentType.ValueEquals("usage") && + content.TryGetProperty("usage", out JsonElement contentUsage)) + { + if (contentUsage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Durable agent usage content requires an object-valued usage property."); + } + + ValidateUsageObject(contentUsage, "message.contents.usage"); + } + + ValidateKnownContentFields(content, contentType.GetString()!); + } + } + } + + private static void ValidateKnownContentFields(JsonElement content, string contentType) + { + switch (contentType) + { + case "data": + RequireString(content, "uri", contentType); + OptionalString(content, "mediaType", contentType); + break; + case "error": + OptionalString(content, "message", contentType); + OptionalString(content, "errorCode", contentType); + break; + case "functionCall": + RequireString(content, "callId", contentType); + RequireString(content, "name", contentType); + break; + case "functionResult": + RequireString(content, "callId", contentType); + break; + case "hostedFile": + RequireString(content, "fileId", contentType); + break; + case "hostedVectorStore": + RequireString(content, "vectorStoreId", contentType); + break; + case "text": + RequireString(content, "text", contentType); + break; + case "reasoning": + OptionalString(content, "text", contentType); + break; + case "uri": + RequireString(content, "uri", contentType); + break; + case "usage": + if (!content.TryGetProperty("usage", out JsonElement usage) || + usage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Durable agent usage content requires an object-valued usage property."); + } + + break; + case "unknown": + if (!content.TryGetProperty("content", out _)) + { + throw new InvalidOperationException( + "Durable agent unknown content requires the original content value."); + } + + break; + } + } + + private static void RequireString(JsonElement element, string propertyName, string contentType) + { + if (!element.TryGetProperty(propertyName, out JsonElement value) || + value.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Durable agent '{contentType}' content requires string property '{propertyName}'."); + } + } + + private static void OptionalString(JsonElement element, string propertyName, string contentType) + { + if (element.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Durable agent '{contentType}' content property '{propertyName}' must be a string when present."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index 294453c..a32943a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -25,11 +26,35 @@ internal sealed class DurableAgentStateMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? CreatedAt { get; init; } + /// + /// Gets the stable message identifier. + /// + [JsonPropertyName("messageId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MessageId { get; set; } + + /// + /// Gets producer-defined message values from the schema's declared extensionData field. + /// + /// + /// The CLR name mirrors so conversion does not invent a + /// second metadata vocabulary. The wire name remains extensionData for cross-language schema + /// compatibility. This declared field is distinct from , which captures + /// undeclared future members adjacent to the message's known JSON fields. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? AdditionalProperties { get; init; } + /// /// Gets the contents of this message. /// [JsonPropertyName("contents")] - public IReadOnlyList Contents { get; init; } = []; + public IReadOnlyList Contents + { + get; + init => field = value ?? []; + } = []; /// /// Gets the role of the message sender (e.g., "user", "assistant", "system"). @@ -38,24 +63,78 @@ internal sealed class DurableAgentStateMessage public required string Role { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets undeclared future message properties that appear beside the schema's known fields. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . /// /// The to convert. + /// The stable identifier to use when the message does not already have one. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original message. - public static DurableAgentStateMessage FromChatMessage(ChatMessage message) + public static DurableAgentStateMessage FromChatMessage( + ChatMessage message, + string? generatedMessageId = null, + ILogger? logger = null) + => 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: logger); + + private static DurableAgentStateMessage FromChatMessage( + ChatMessage message, + string? generatedMessageId, + bool requireJsonSafeMetadata, + ILogger? logger = null) { + string role = message.Role.ToString(); + if (!requireJsonSafeMetadata && + role is not ("user" or "assistant" or "system" or "tool")) + { + throw new InvalidOperationException( + $"The legacy durable agent state cannot persist message role '{role}'."); + } + + Dictionary? additionalProperties = null; + if (message.AdditionalProperties is not null) + { + foreach ((string key, object? value) in message.AdditionalProperties) + { + JsonElement element = requireJsonSafeMetadata + ? DurableAgentStateTerminalResponse.ConvertMetadata(value, key) + : JsonSerializer.SerializeToElement( + value, + DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + additionalProperties ??= []; + additionalProperties[key] = element; + } + } + return new DurableAgentStateMessage() { CreatedAt = message.CreatedAt, AuthorName = message.AuthorName, - Role = message.Role.ToString(), - Contents = message.Contents.Select(DurableAgentStateContent.FromAIContent).ToList() + MessageId = message.MessageId ?? generatedMessageId, + AdditionalProperties = additionalProperties, + Role = role, + Contents = message.Contents.Select(content => + requireJsonSafeMetadata + ? DurableAgentStateContent.FromAIContentV2(content, logger) + : DurableAgentStateContent.FromAIContent(content, logger)).ToList() }; } @@ -65,12 +144,73 @@ public static DurableAgentStateMessage FromChatMessage(ChatMessage message) /// A representing this message. public ChatMessage ToChatMessage() { + 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 = this.Contents.Select(c => c.ToAIContent()).ToList(), Role = new(this.Role) }; } + + public void ValidateV2() + { + if (this.Role is not "user" and + not "assistant" and + not "system" and + not "developer" and + not "tool") + { + throw new InvalidOperationException( + $"The durable agent state message role '{this.Role}' is not supported."); + } + + if (this.Contents.Any(static content => content is null)) + { + throw new InvalidOperationException( + "A durable agent state message cannot contain null content entries."); + } + + foreach (DurableAgentStateContent content in this.Contents) + { + 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(static content => content.ToAIContent()), + Role = new(this.Role), + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs new file mode 100644 index 0000000..46e72c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Assigns deterministic identities to durable messages that predate schema 1.2. +/// +internal static class DurableAgentStateMessageIdentity +{ + public static void EnsureMessageIds(IEnumerable history) + { + foreach (DurableAgentStateEntry entry in history) + { + string entryType = entry switch + { + DurableAgentStateErrorResponse => "errorResponse", + DurableAgentStateRequest => "request", + DurableAgentStateResponse => "response", + DurableAgentStateCompaction => "compaction", + _ => throw new InvalidOperationException( + $"Unsupported durable agent state entry type '{entry.GetType()}'."), + }; + + for (int index = 0; index < entry.Messages.Count; index++) + { + DurableAgentStateMessage message = entry.Messages[index]; + if (message.MessageId is null) + { + message.MessageId = Create( + entryType, + entry.CorrelationId, + entry.CreatedAt ?? default, + index); + } + } + } + } + + public static string Create( + string entryType, + string? correlationId, + DateTimeOffset createdAt, + int storedIndex) + { + string scope = string.IsNullOrEmpty(correlationId) + ? FormatPythonIsoTimestamp(createdAt) + : correlationId; + return $"durable_{entryType}_{scope}_{storedIndex}"; + } + + internal static string FormatPythonIsoTimestamp(DateTimeOffset timestamp) + { + long microseconds = timestamp.Ticks % TimeSpan.TicksPerSecond / 10; + string fraction = microseconds == 0 + ? string.Empty + : $".{microseconds.ToString("D6", CultureInfo.InvariantCulture)}"; + return string.Concat( + timestamp.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture), + fraction, + timestamp.ToString("zzz", CultureInfo.InvariantCulture)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateOutcomeResolver.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateOutcomeResolver.cs new file mode 100644 index 0000000..aa455f2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateOutcomeResolver.cs @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Resolves durable request outcomes across legacy transcript and revised mailbox layouts. +/// +internal static class DurableAgentStateOutcomeResolver +{ + private const string LegacyErrorCode = "legacyErrorResponse"; + private const string LegacyErrorMessage = + "The durable agent request completed with a recorded terminal error."; + + public static DurableAgentRunOutcome Resolve( + DurableAgentState state, + string correlationId, + DateTimeOffset currentTime) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentException.ThrowIfNullOrWhiteSpace(correlationId); + + DurableAgentStateSchemaVersion version = + DurableAgentStateSchemaVersion.ParseSupported(state.SchemaVersion); + try + { + state.Data.Validate(state.SchemaVersion); + } + catch (InvalidOperationException exception) + { + throw new DurableAgentStateCorruptionException("The durable agent outcome state is inconsistent.", exception); + } + + return version.Major == DurableAgentState.RevisedSchemaMajorVersion + ? ResolveRevised(state, correlationId, currentTime) + : ResolveLegacy(state.Data.ConversationHistory, correlationId); + } + + /// + /// Creates a revised working state and migrates every evidenced legacy terminal entry. + /// + public static DurableAgentState PrepareRevisedWorkingState( + DurableAgentState state, + bool hasAuthoritativeLegacyHistory = false) + { + ArgumentNullException.ThrowIfNull(state); + + DurableAgentStateSchemaVersion version = + DurableAgentStateSchemaVersion.ParseSupported(state.SchemaVersion); + if (version.Major != DurableAgentState.RevisedSchemaMajorVersion && + (!hasAuthoritativeLegacyHistory || + state.Data.Truncation is not null || + state.Data.ConversationHistory.Any(entry => entry is DurableAgentStateCompaction))) + { + throw new InvalidOperationException( + "Legacy mailbox migration requires independently authoritative complete history. " + + "Retained or pruned transcripts cannot establish all previous completions; retain legacy state or use an isolated new generation."); + } + + DurableAgentState clone = state.Clone(); + if (version.Major == DurableAgentState.RevisedSchemaMajorVersion) + { + clone.MailboxWritesAuthorized = true; + return clone; + } + + Dictionary terminalResults = + new(StringComparer.Ordinal); + Dictionary completionReceipts = + new(StringComparer.Ordinal); + + foreach (DurableAgentStateResponse response in clone.Data.ConversationHistory + .OfType()) + { + if (response.CorrelationId is null) + { + // Uncorrelated transcript content is not delivery evidence for a request identity. + continue; + } + + try + { + DurableAgentStateContract.ValidateIdentifier( + response.CorrelationId, + "conversationHistory.correlationId"); + } + catch (InvalidOperationException exception) + { + throw new DurableAgentStateCorruptionException( + "A legacy terminal response has an invalid correlation ID.", + exception); + } + + string correlationId = response.CorrelationId!; + if (terminalResults.ContainsKey(correlationId)) + { + int count = clone.Data.ConversationHistory + .OfType() + .Count(candidate => string.Equals( + candidate.CorrelationId, + correlationId, + StringComparison.Ordinal)); + throw new DurableAgentStateCorruptionException(correlationId, count); + } + + DurableAgentStateTerminalResult result = ConvertLegacyTerminal(response); + terminalResults.Add(correlationId, result); + completionReceipts.Add(correlationId, CreateAvailableReceipt(result)); + } + + return new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + ConversationHistory = clone.Data.ConversationHistory, + TerminalResults = terminalResults, + CompletionReceipts = completionReceipts, + HistoryBinding = clone.Data.HistoryBinding, + Session = clone.Data.Session, + IngestedPositions = clone.Data.IngestedPositions, + Truncation = clone.Data.Truncation, + ExpirationTimeUtc = clone.Data.ExpirationTimeUtc, + ExtensionData = clone.Data.ExtensionData, + UnknownProperties = clone.Data.UnknownProperties, + }, + ExtensionData = clone.ExtensionData, + UnknownProperties = clone.UnknownProperties, + }; + } + + public static void AddSuccessfulResult( + DurableAgentState state, + string correlationId, + AgentResponse response, + DateTimeOffset completedAt, + DateTimeOffset? resultExpiresAt = null, + JsonElement structuredValue = default, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(state); + DurableAgentStateTerminalResult result = + DurableAgentStateTerminalResult.FromResponse( + correlationId, + response, + completedAt, + resultExpiresAt, + structuredValue, + logger: logger); + + IDictionary terminalResults = + state.Data.TerminalResults ?? + throw new InvalidOperationException("A revised durable state requires a terminal result mailbox."); + IDictionary completionReceipts = + state.Data.CompletionReceipts ?? + throw new InvalidOperationException("A revised durable state requires completion receipts."); + if (terminalResults.ContainsKey(correlationId) || completionReceipts.ContainsKey(correlationId)) + { + throw new DurableAgentStateCorruptionException( + $"Durable agent state already contains a committed outcome for correlation '{correlationId}'."); + } + + terminalResults.Add(correlationId, result); + completionReceipts.Add(correlationId, CreateAvailableReceipt(result)); + } + + /// + /// Removes an expired payload while retaining its authoritative completion receipt. + /// + public static bool MarkExpiredResultUnavailable( + DurableAgentState state, + string correlationId, + DateTimeOffset currentTime) + { + DurableAgentRunOutcome outcome = Resolve(state, correlationId, currentTime); + if (outcome.Kind != DurableAgentRunOutcomeKind.CompletedResultUnavailable || + outcome.Receipt?.ResultState != DurableAgentStateCompletionReceipt.AvailableResult) + { + return false; + } + + IDictionary terminalResults = + state.Data.TerminalResults ?? + throw new InvalidOperationException("A revised durable state requires a terminal result mailbox."); + IDictionary completionReceipts = + state.Data.CompletionReceipts ?? + throw new InvalidOperationException("A revised durable state requires completion receipts."); + DurableAgentStateCompletionReceipt receipt = outcome.Receipt; + + terminalResults.Remove(correlationId); + completionReceipts[correlationId] = new DurableAgentStateCompletionReceipt + { + CorrelationId = receipt.CorrelationId, + Outcome = receipt.Outcome, + CompletedAt = receipt.CompletedAt, + ResultState = DurableAgentStateCompletionReceipt.UnavailableResult, + ResultExpiresAt = receipt.ResultExpiresAt, + ResultUnavailableAt = currentTime, + UnknownProperties = CloneElements(receipt.UnknownProperties), + }; + return true; + } + + private static DurableAgentRunOutcome ResolveRevised( + DurableAgentState state, + string correlationId, + DateTimeOffset currentTime) + { + IDictionary terminalResults = + state.Data.TerminalResults ?? + throw new DurableAgentStateCorruptionException( + "Revised durable agent state is missing the terminal result mailbox."); + IDictionary completionReceipts = + state.Data.CompletionReceipts ?? + throw new DurableAgentStateCorruptionException( + "Revised durable agent state is missing completion receipts."); + + bool hasResult = terminalResults.TryGetValue( + correlationId, + out DurableAgentStateTerminalResult? result); + bool hasReceipt = completionReceipts.TryGetValue( + correlationId, + out DurableAgentStateCompletionReceipt? receipt); + + if (!hasReceipt) + { + if (hasResult) + { + throw new DurableAgentStateCorruptionException( + $"Durable agent terminal result '{correlationId}' has no completion receipt."); + } + + // Revised state never falls back to transcript delivery evidence. + return DurableAgentRunOutcome.Pending; + } + + if (receipt!.ResultState == DurableAgentStateCompletionReceipt.UnavailableResult) + { + if (hasResult) + { + throw new DurableAgentStateCorruptionException( + $"Unavailable durable agent result '{correlationId}' still has a payload."); + } + + return DurableAgentRunOutcome.CompletedResultUnavailable(receipt); + } + + if (!hasResult) + { + throw new DurableAgentStateCorruptionException( + $"Available durable agent result '{correlationId}' has no payload."); + } + + if (receipt.Outcome != result!.Outcome || + receipt.CompletedAt != result.CompletedAt || + receipt.ResultExpiresAt != result.ResultExpiresAt) + { + throw new DurableAgentStateCorruptionException( + $"Durable agent result '{correlationId}' is inconsistent with its completion receipt."); + } + + if (result.ResultExpiresAt is DateTimeOffset expiresAt && currentTime >= expiresAt) + { + return DurableAgentRunOutcome.CompletedResultUnavailable(receipt); + } + + AgentResponse response = result.Response?.ToResponse(ToDeliveryMessage) ?? + throw new DurableAgentStateCorruptionException( + $"Durable agent result '{correlationId}' has no response payload."); + DurableAgentJsonUtilities.CaptureRetainedResult(response, result.Response); + return result.Outcome == DurableAgentStateCompletionReceipt.SucceededOutcome + ? DurableAgentRunOutcome.Succeeded(response, receipt) with { Value = result.Response.Value } + : DurableAgentRunOutcome.Failed( + response, + result.Error ?? + throw new DurableAgentStateCorruptionException( + $"Failed durable agent result '{correlationId}' has no error metadata."), + receipt) with + { Value = result.Response.Value }; + } + + private static ChatMessage ToDeliveryMessage(DurableAgentStateMessage message) + { + return new ChatMessage + { + CreatedAt = message.CreatedAt, + AuthorName = message.AuthorName, + MessageId = message.MessageId, + Role = new ChatRole(message.Role), + AdditionalProperties = message.AdditionalProperties is null + ? null + : new AdditionalPropertiesDictionary(message.AdditionalProperties.Select( + pair => new KeyValuePair(pair.Key, pair.Value))), + Contents = message.Contents.Select(static content => + content is DurableAgentStateUriContent { MediaType: null } + // This valid shared shape has no typed .NET equivalent. Preserve its exact + // JSON as opaque content rather than inventing a media type or failing delivery. + ? new DurableAgentStateUnknownContent + { + Content = JsonSerializer.SerializeToElement( + content, DurableAgentStateJsonContext.Default.DurableAgentStateContent), + }.ToAIContent() + : content.ToAIContent()).ToList(), + }; + } + + private static DurableAgentRunOutcome ResolveLegacy( + IEnumerable history, + string correlationId) + { + List matches = history + .OfType() + .Where(response => string.Equals( + response.CorrelationId, + correlationId, + StringComparison.Ordinal)) + .ToList(); + + if (matches.Count > 1) + { + throw new DurableAgentStateCorruptionException(correlationId, matches.Count); + } + + if (matches.Count == 0) + { + return DurableAgentRunOutcome.Pending; + } + + DurableAgentStateResponse response = matches[0]; + AgentResponse agentResponse = response.ToResponse(); + DurableAgentJsonUtilities.CaptureRetainedLegacyResult(agentResponse, response); + return response is DurableAgentStateErrorResponse + ? DurableAgentRunOutcome.Failed( + agentResponse, + CreateLegacyError(), + receipt: null) + : DurableAgentRunOutcome.Succeeded(agentResponse, receipt: null); + } + + private static DurableAgentStateTerminalResult ConvertLegacyTerminal( + DurableAgentStateResponse response) + { + bool failed = response is DurableAgentStateErrorResponse; + DateTimeOffset completedAt = response.CreatedAt ?? + response.Messages.Max(message => message.CreatedAt) ?? + throw new DurableAgentStateCorruptionException( + $"Legacy terminal response '{response.CorrelationId}' has no evidenced completion timestamp."); + DurableAgentStateTerminalResponse snapshot = new() + { + Messages = response.Messages, + Usage = response.Usage, + CreatedAt = response.CreatedAt, + }; + // Preserve opaque legacy message/usage metadata rather than round-tripping it through + // the lossy runtime projection. The mailbox must not alias the evictable transcript. + snapshot = JsonSerializer.Deserialize( + JsonSerializer.SerializeToUtf8Bytes(snapshot, DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResponse), + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResponse)!; + return new DurableAgentStateTerminalResult + { + CorrelationId = response.CorrelationId!, + Outcome = failed + ? DurableAgentStateCompletionReceipt.FailedOutcome + : DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + Response = snapshot, + Error = failed ? CreateLegacyError() : null, + }; + } + + private static DurableAgentStateTerminalError CreateLegacyError() => + new() + { + Code = LegacyErrorCode, + Message = LegacyErrorMessage, + }; + + private static DurableAgentStateCompletionReceipt CreateAvailableReceipt( + DurableAgentStateTerminalResult result) => + new() + { + CorrelationId = result.CorrelationId, + Outcome = result.Outcome, + CompletedAt = result.CompletedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + ResultExpiresAt = result.ResultExpiresAt, + }; + + private static Dictionary? CloneElements( + IDictionary? values) => + values?.ToDictionary( + pair => pair.Key, + pair => pair.Value.Clone(), + StringComparer.Ordinal); +} 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 6349b97..df9e355 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -15,6 +16,7 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// Gets the ID of the orchestration that initiated this request (if any). /// [JsonPropertyName("orchestrationId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? OrchestrationId { get; init; } /// @@ -24,6 +26,7 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// If omitted, the expectation is that the agent will respond in plain text. /// [JsonPropertyName("responseType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ResponseType { get; init; } /// @@ -41,15 +44,49 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// Creates a from a . /// /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original request. - public static DurableAgentStateRequest FromRunRequest(RunRequest request) + public static DurableAgentStateRequest FromRunRequest( + RunRequest request, + ILogger? logger = null) + => FromRunRequestCore(request, request.Messages, allowLosslessV2: false, logger); + + internal static DurableAgentStateRequest FromRunRequestV2( + RunRequest request, + ILogger? logger = null) + => FromRunRequestCore(request, request.Messages, allowLosslessV2: true, logger); + + 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) { + DateTimeOffset createdAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow; return new DurableAgentStateRequest() { CorrelationId = request.CorrelationId, OrchestrationId = request.OrchestrationId, - Messages = request.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), - CreatedAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, + Messages = messages.Select( + (message, index) => + { + string messageId = DurableAgentStateMessageIdentity.Create( + "request", + request.CorrelationId, + createdAt, + index); + return allowLosslessV2 + ? DurableAgentStateMessage.FromTerminalChatMessage(message, messageId, logger) + : DurableAgentStateMessage.FromChatMessage(message, messageId, logger); + }).ToList(), + CreatedAt = createdAt, ResponseType = request.ResponseFormat is ChatResponseFormatJson ? "json" : "text", ResponseSchema = (request.ResponseFormat as ChatResponseFormatJson)?.Schema }; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs index fb9f23d..06ab3d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -2,13 +2,14 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; /// /// Represents a durable agent state entry that is a response from the agent. /// -internal sealed class DurableAgentStateResponse : DurableAgentStateEntry +internal class DurableAgentStateResponse : DurableAgentStateEntry { /// /// Gets the usage details for this state response. @@ -22,21 +23,73 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry /// /// The correlation ID linking this response to its request. /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original response. - public static DurableAgentStateResponse FromResponse(string correlationId, AgentResponse response) + public static DurableAgentStateResponse FromResponse( + string correlationId, + AgentResponse response, + ILogger? logger = null) + => FromResponse(correlationId, response, allowLosslessV2: false, logger); + + internal static DurableAgentStateResponse FromResponseV2( + string correlationId, + AgentResponse response, + ILogger? logger = null) + => FromResponse(correlationId, response, allowLosslessV2: true, logger); + + private static DurableAgentStateResponse FromResponse( + string correlationId, + AgentResponse response, + bool allowLosslessV2, + ILogger? logger) { + List messages = response.Messages.ToList(); + DateTimeOffset createdAt = response.CreatedAt ?? GetCreatedAt(messages); return new DurableAgentStateResponse() { CorrelationId = correlationId, - CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, - Messages = response.Messages - .Where(HasSerializableContent) - .Select(DurableAgentStateMessage.FromChatMessage) - .ToList(), + CreatedAt = createdAt, + Messages = CreateStoredMessages(messages, correlationId, createdAt, logger, allowLosslessV2), Usage = DurableAgentStateUsage.FromUsage(response.Usage) }; } + /// + /// Creates a response entry from response messages before aggregate response metadata is available. + /// + 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); + return new DurableAgentStateResponse() + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = CreateStoredMessages( + messageList, + correlationId, + createdAt, + logger, + allowLosslessV2), + }; + } + /// /// Converts this back to an . /// @@ -51,17 +104,35 @@ public AgentResponse ToResponse() }; } - // Checks whether a ChatMessage has any content that will produce meaningful serialized data. - // Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable. - // Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and - // AdditionalProperties. We keep the message if any base AIContent has annotations or additional - // properties set. NOTE: if AIContent gains new serializable properties in the future, this check - // should be updated accordingly. - private static bool HasSerializableContent(ChatMessage message) + private static List CreateStoredMessages( + IEnumerable messages, + string correlationId, + DateTimeOffset createdAt, + ILogger? logger, + bool allowLosslessV2 = false) + { + return messages + .Select((message, storedIndex) => + { + string messageId = DurableAgentStateMessageIdentity.Create( + "response", + correlationId, + createdAt, + storedIndex); + return allowLosslessV2 + ? DurableAgentStateMessage.FromTerminalChatMessage(message, messageId, logger) + : DurableAgentStateMessage.FromChatMessage(message, messageId, logger); + }) + .ToList(); + } + + private static DateTimeOffset GetCreatedAt(IReadOnlyList messages) { - return message.Contents.Any(c => - c.GetType() != typeof(AIContent) || - c.Annotations?.Count > 0 || - c.AdditionalProperties?.Count > 0); + return messages + .Select(message => message.CreatedAt) + .Where(createdAt => createdAt.HasValue) + .Select(createdAt => createdAt!.Value) + .DefaultIfEmpty(DateTimeOffset.UtcNow) + .Max(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs new file mode 100644 index 0000000..c664081 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Numerics; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Centralizes strict parsing, supported-major validation, and ordering for durable state versions. +/// +internal readonly record struct DurableAgentStateSchemaVersion(BigInteger Major, BigInteger Minor, BigInteger Patch) + : IComparable +{ + private static readonly HashSet s_supportedVersions = + [ + "1.0.0", + "1.1.0", + DurableAgentState.CurrentSchemaVersion, + DurableAgentState.RevisedSchemaVersion, + ]; + + /// + /// Parses and validates a supported durable agent state schema version. + /// + public static DurableAgentStateSchemaVersion ParseSupported(string? value) + { + if (value is null || !s_supportedVersions.Contains(value)) + { + throw new InvalidOperationException($"The durable agent state schema version '{value}' is not supported."); + } + + _ = TryParse(value, out DurableAgentStateSchemaVersion version); + return version; + } + + /// + /// Parses the schema's strict numeric major.minor.patch grammar. + /// + public static bool TryParse(string? value, out DurableAgentStateSchemaVersion version) + { + version = default; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + ReadOnlySpan remaining = value.AsSpan(); + if (!TryReadComponent(ref remaining, out BigInteger major) || + !TryReadComponent(ref remaining, out BigInteger minor) || + !TryReadFinalComponent(remaining, out BigInteger patch)) + { + return false; + } + + version = new(major, minor, patch); + return true; + } + + /// + public int CompareTo(DurableAgentStateSchemaVersion other) + { + int majorComparison = this.Major.CompareTo(other.Major); + if (majorComparison != 0) + { + return majorComparison; + } + + int minorComparison = this.Minor.CompareTo(other.Minor); + return minorComparison != 0 + ? minorComparison + : this.Patch.CompareTo(other.Patch); + } + + private static bool TryReadComponent(ref ReadOnlySpan value, out BigInteger component) + { + int separatorIndex = value.IndexOf('.'); + if (separatorIndex <= 0 || + !TryParseNumericIdentifier(value[..separatorIndex], out component)) + { + component = default; + return false; + } + + value = value[(separatorIndex + 1)..]; + return true; + } + + private static bool TryReadFinalComponent(ReadOnlySpan value, out BigInteger component) + { + component = default; + return value.IndexOf('.') < 0 && TryParseNumericIdentifier(value, out component); + } + + private static bool TryParseNumericIdentifier(ReadOnlySpan value, out BigInteger component) + { + component = 0; + if (value.IsEmpty || (value.Length > 1 && value[0] == '0')) + { + return false; + } + + foreach (char character in value) + { + if (character is < '0' or > '9') + { + return false; + } + + component = (component * 10) + (character - '0'); + } + + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs new file mode 100644 index 0000000..4f50a7c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// JSON-safe failure metadata for a terminal result. +/// +internal sealed class DurableAgentStateTerminalError +{ + [JsonPropertyName("code")] + public required string Code { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Details + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate() + { + DurableAgentStateContract.ValidateIdentifier(this.Code, "terminalResults.error.code"); + if (string.IsNullOrWhiteSpace(this.Message) || + this.Message.EnumerateRunes() + .Take(DurableAgentStateContract.MaxMetadataStringLength + 1) + .Count() > DurableAgentStateContract.MaxMetadataStringLength) + { + throw new InvalidOperationException( + $"The durable agent terminal error message must be non-empty, at most {DurableAgentStateContract.MaxMetadataStringLength} characters, and contain no control characters."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs new file mode 100644 index 0000000..903db90 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.State; + +#pragma warning disable MEAI001 // ResponseContinuationToken is part of the AgentResponse contract captured here. + +/// +/// Immutable, JSON-safe projection of the fields consumed from an . +/// +internal sealed class DurableAgentStateTerminalResponse +{ + [JsonPropertyName("messages")] + public IReadOnlyList Messages + { + get; + init => field = value ?? []; + } = []; + + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateUsage? Usage { get; init; } + + [JsonPropertyName("createdAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } + + /// + /// Gets an optional caller-visible JSON result independent of the response messages. + /// + /// + /// means the wire property was absent. All other JSON values, + /// including explicit null, false, zero, empty strings, arrays, and objects, are present values. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Value + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + + [JsonPropertyName("responseId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResponseId { get; init; } + + [JsonPropertyName("agentId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AgentId { get; init; } + + [JsonPropertyName("finishReason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; init; } + + [JsonPropertyName("continuationToken")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContinuationToken { get; init; } + + /// + /// Gets JSON-safe producer-defined values from the declared wire-level extensionData field. + /// + /// + /// The CLR name mirrors . Keeping that name makes the + /// projection boundary explicit while preserves the shared + /// schema name. This field is not the same as , which contains + /// undeclared future JSON members. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? AdditionalProperties { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public static DurableAgentStateTerminalResponse FromResponse( + AgentResponse response, + string correlationId, + DateTimeOffset completedAt, + JsonElement structuredValue = default, + ILogger? logger = null) + { + Dictionary? additionalProperties = null; + if (response.AdditionalProperties is not null) + { + foreach ((string key, object? value) in response.AdditionalProperties) + { + additionalProperties ??= []; + additionalProperties[key] = ConvertMetadata(value, key); + } + } + + return new() + { + Messages = response.Messages + .Select((message, index) => DurableAgentStateMessage.FromTerminalChatMessage( + message, + DurableAgentStateMessageIdentity.Create("result", correlationId, completedAt, index), + logger)) + .ToList(), + Usage = DurableAgentStateUsage.FromUsage(response.Usage), + CreatedAt = response.CreatedAt, + Value = structuredValue, + ResponseId = response.ResponseId, + AgentId = response.AgentId, + FinishReason = response.FinishReason?.Value, + ContinuationToken = response.ContinuationToken is null + ? null + : Convert.ToBase64String(response.ContinuationToken.ToBytes().Span), + AdditionalProperties = additionalProperties, + }; + } + + public AgentResponse ToResponse() => this.ToResponse(static message => message.ToChatMessage()); + + internal AgentResponse ToResponse(Func messageConverter) + { + AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null + ? null + : new(this.AdditionalProperties.Select(pair => + new KeyValuePair(pair.Key, pair.Value))); + + return new AgentResponse + { + Messages = this.Messages.Select(messageConverter).ToList(), + Usage = this.Usage?.ToUsageDetails(), + CreatedAt = this.CreatedAt, + ResponseId = this.ResponseId, + AgentId = this.AgentId, + FinishReason = this.FinishReason is null ? null : new ChatFinishReason(this.FinishReason), + ContinuationToken = this.ContinuationToken is null + ? null + : ResponseContinuationToken.FromBytes(Convert.FromBase64String(this.ContinuationToken)), + AdditionalProperties = additionalProperties, + }; + } + + public void Validate() + { + if (this.Messages is null) + { + throw new InvalidOperationException( + "A durable agent terminal response requires a messages collection."); + } + + foreach (DurableAgentStateMessage? message in this.Messages) + { + if (message is null || message.Contents is null) + { + throw new InvalidOperationException( + "A durable agent terminal response cannot contain null messages or content collections."); + } + + message.ValidateV2(); + } + + ValidateOptionalIdentifier(this.ResponseId, "terminalResults.response.responseId"); + ValidateOptionalIdentifier(this.AgentId, "terminalResults.response.agentId"); + ValidateOptionalIdentifier(this.FinishReason, "terminalResults.response.finishReason"); + + if (this.ContinuationToken is not null) + { + if (this.ContinuationToken.Length > DurableAgentStateContract.MaxMetadataStringLength) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token is too large."); + } + + try + { + byte[] decoded = Convert.FromBase64String(this.ContinuationToken); + if (!string.Equals( + this.ContinuationToken, + Convert.ToBase64String(decoded), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token must use canonical base64 encoding."); + } + } + catch (FormatException exception) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token must be base64 encoded.", + exception); + } + } + + if (this.AdditionalProperties is not null) + { + foreach (string key in this.AdditionalProperties.Keys) + { + DurableAgentStateContract.ValidateIdentifier(key, "terminalResults.response.extensionData key"); + } + } + } + + private static void ValidateOptionalIdentifier(string? value, string propertyName) + { + if (value is not null) + { + DurableAgentStateContract.ValidateIdentifier(value, propertyName); + } + } + + internal static JsonElement ConvertMetadata(object? value, string propertyName) + { + switch (value) + { + case null: + return JsonSerializer.SerializeToElement( + value, + DurableAgentStateJsonContext.Default.Object); + case JsonElement jsonElement: + return jsonElement.Clone(); + case string text when text.Length <= DurableAgentStateContract.MaxMetadataStringLength: + return JsonSerializer.SerializeToElement( + text, + DurableAgentStateJsonContext.Default.String); + case bool boolean: + return JsonSerializer.SerializeToElement( + boolean, + DurableAgentStateJsonContext.Default.Boolean); + case int integer: + return JsonSerializer.SerializeToElement( + integer, + DurableAgentStateJsonContext.Default.Int32); + case long longInteger: + return JsonSerializer.SerializeToElement( + longInteger, + DurableAgentStateJsonContext.Default.Int64); + case double doubleValue when double.IsFinite(doubleValue): + return JsonSerializer.SerializeToElement( + doubleValue, + DurableAgentStateJsonContext.Default.Double); + case decimal decimalValue: + return JsonSerializer.SerializeToElement( + decimalValue, + DurableAgentStateJsonContext.Default.Decimal); + case DateTimeOffset dateTimeOffset: + return JsonSerializer.SerializeToElement( + dateTimeOffset, + DurableAgentStateJsonContext.Default.DateTimeOffset); + default: + throw new InvalidOperationException( + $"The AgentResponse metadata property '{propertyName}' has unsupported runtime type '{value?.GetType()}'."); + } + } + +#pragma warning restore MEAI001 +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs new file mode 100644 index 0000000..58dce17 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Immutable terminal result envelope detached from evictable conversation history. +/// +/// +/// A later runtime layer must commit this result and its matching receipt in the same durable entity +/// operation as session continuation, ingestion bookkeeping, entity-local transcript, TTL, optional +/// binding, and other local control state. This DTO performs no commit or delivery behavior. +/// +internal sealed class DurableAgentStateTerminalResult +{ + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + [JsonPropertyName("outcome")] + public required string Outcome { get; init; } + + [JsonPropertyName("completedAt")] + public required DateTimeOffset CompletedAt { get; init; } + + [JsonPropertyName("resultExpiresAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultExpiresAt { get; init; } + + [JsonPropertyName("response")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTerminalResponse? Response { get; init; } + + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTerminalError? Error { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public static DurableAgentStateTerminalResult FromResponse( + string correlationId, + AgentResponse response, + DateTimeOffset completedAt, + DateTimeOffset? resultExpiresAt = null, + JsonElement structuredValue = default, + ILogger? logger = null) + { + DurableAgentStateContract.ValidateIdentifier(correlationId, "terminalResults.correlationId"); + return new() + { + CorrelationId = correlationId, + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultExpiresAt = resultExpiresAt, + Response = DurableAgentStateTerminalResponse.FromResponse( + response, + correlationId, + completedAt, + structuredValue, + logger), + }; + } + + public void Validate(string dictionaryKey) + { + DurableAgentStateContract.ValidateIdentifier(dictionaryKey, "terminalResults key"); + DurableAgentStateContract.ValidateIdentifier(this.CorrelationId, "terminalResults.correlationId"); + if (!string.Equals(dictionaryKey, this.CorrelationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The durable agent state terminal result key '{dictionaryKey}' does not match correlation ID '{this.CorrelationId}'."); + } + + if (this.Outcome is not DurableAgentStateCompletionReceipt.SucceededOutcome and + not DurableAgentStateCompletionReceipt.FailedOutcome) + { + throw new InvalidOperationException( + $"The durable agent state terminal outcome '{this.Outcome}' is not supported."); + } + + if (this.CompletedAt == default) + { + throw new InvalidOperationException( + "A durable agent terminal result requires a completion timestamp."); + } + + if (this.Response is null) + { + throw new InvalidOperationException( + "A durable agent terminal result must contain a response payload."); + } + + if (this.Outcome == DurableAgentStateCompletionReceipt.SucceededOutcome && this.Error is not null) + { + throw new InvalidOperationException( + "A successful durable agent terminal result cannot contain error metadata."); + } + + if (this.Outcome == DurableAgentStateCompletionReceipt.FailedOutcome && this.Error is null) + { + throw new InvalidOperationException( + "A failed durable agent terminal result must contain error metadata."); + } + + if (this.ResultExpiresAt < this.CompletedAt) + { + throw new InvalidOperationException( + "The durable agent terminal result expiry cannot precede completion."); + } + + this.Response.Validate(); + this.Error?.Validate(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs new file mode 100644 index 0000000..2769ae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Bounded diagnostic evidence that durable transcript messages were removed. +/// +/// +/// This object survives the removed entries so a persisted gap is not mistaken for history that never +/// existed. It does not contain model context and does not establish request delivery or completion. +/// +internal sealed class DurableAgentStateTruncation +{ + /// + /// Gets or sets the cumulative number of transcript messages known to have been removed. + /// + [JsonPropertyName("evictedMessageCount")] + public int EvictedMessageCount { get; set; } + + /// + /// Gets or sets when transcript removal was first recorded. + /// + [JsonPropertyName("firstEvictedAt")] + public DateTimeOffset FirstEvictedAt { get; set; } + + /// + /// Gets or sets when transcript removal was most recently recorded. + /// + [JsonPropertyName("lastEvictedAt")] + public DateTimeOffset LastEvictedAt { get; set; } + + /// + /// Gets undeclared future truncation evidence fields. + /// + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate() + { + if (this.EvictedMessageCount < 1 || + this.FirstEvictedAt == default || + this.LastEvictedAt == default || + this.LastEvictedAt < this.FirstEvictedAt) + { + throw new InvalidOperationException( + "Durable agent truncation evidence requires a positive count and ordered first/latest timestamps."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs index 00a180b..05bd4e4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -11,6 +15,19 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent { + private const string DurableEnvelopePropertyName = "$microsoftAgentFrameworkDurableTask"; + private const string KindPropertyName = "kind"; + private const string VersionPropertyName = "version"; + private const string AnnotationsPropertyName = "annotations"; + private const string AdditionalPropertiesPropertyName = "additionalProperties"; + private const string RawRepresentationPropertyName = "rawRepresentation"; + private const string AnnotatedRegionsPropertyName = "annotatedRegions"; + private const string OmittedPropertyName = "omitted"; + private const string UnknownContentKind = "unknownAIContent"; + private const int DurableEnvelopeVersion = 1; + + private static readonly JsonElement s_minimalUnknownContent = CreateMinimalUnknownContent(); + /// /// Gets the serialized unknown content. /// @@ -21,23 +38,678 @@ internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent /// Creates a from an . /// /// The to convert. + /// The logger used to report safe serialization fallbacks. /// A representing the original content. - public static DurableAgentStateUnknownContent FromUnknownContent(AIContent content) + public static DurableAgentStateUnknownContent FromUnknownContent( + AIContent content, + ILogger? logger = null) { - return new DurableAgentStateUnknownContent() + ArgumentNullException.ThrowIfNull(content); + + if (TryGetOpaqueContent(content, logger, out JsonElement opaqueContent)) + { + return new DurableAgentStateUnknownContent { Content = opaqueContent }; + } + + JsonObject envelope = CreateEnvelope(UnknownContentKind); + JsonObject omissions = []; + + AddAnnotations(content.Annotations, envelope, omissions, logger); + AddAdditionalProperties(content.AdditionalProperties, envelope, omissions, logger); + AddRawRepresentation(content.RawRepresentation, envelope, omissions, logger); + + if (omissions.Count > 0) + { + envelope[OmittedPropertyName] = omissions; + } + + return new DurableAgentStateUnknownContent { - Content = JsonSerializer.SerializeToElement( - value: content, - jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) + Content = SerializeEnvelope(envelope, content, logger), }; } /// public override AIContent ToAIContent() { - AIContent? content = this.Content.Deserialize( - jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) as AIContent; + if (TryGetEnvelope(this.Content, out JsonElement envelope, out string? kind) && + kind == UnknownContentKind && + TryReadUnknownContent(envelope, out AIContent unknownContent)) + { + return unknownContent; + } + + return CreateOpaqueAIContent(this.Content); + } + + private static JsonObject CreateEnvelope(string kind) + { + return new JsonObject + { + [KindPropertyName] = kind, + [VersionPropertyName] = DurableEnvelopeVersion, + }; + } + + private static JsonElement CreateMinimalUnknownContent() + { + JsonObject root = new() + { + [DurableEnvelopePropertyName] = CreateEnvelope(UnknownContentKind), + }; + return JsonSerializer.SerializeToElement( + root, + DurableAgentStateJsonContext.Default.JsonObject); + } + + private static JsonElement SerializeEnvelope( + JsonObject envelope, + AIContent source, + ILogger? logger) + { + JsonObject root = new() + { + [DurableEnvelopePropertyName] = envelope, + }; + + try + { + return JsonSerializer.SerializeToElement( + root, + DurableAgentStateJsonContext.Default.JsonObject); + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, source, exception); + return s_minimalUnknownContent.Clone(); + } + } + + private static bool TryGetOpaqueContent( + AIContent content, + ILogger? logger, + out JsonElement opaqueContent) + { + opaqueContent = default; + try + { + if (content.GetType() != typeof(AIContent) || + content.Annotations is { Count: > 0 } || + content.RawRepresentation is not JsonElement rawRepresentation || + content.AdditionalProperties is not { Count: 1 } additionalProperties || + !additionalProperties.TryGetValue("content", out object? storedContent) || + storedContent is not JsonElement storedElement) + { + return false; + } + + if (rawRepresentation.GetRawText() != storedElement.GetRawText()) + { + return false; + } + + opaqueContent = rawRepresentation.Clone(); + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, content, exception); + return false; + } + } + + private static void AddAnnotations( + IList? annotations, + JsonObject envelope, + JsonObject omissions, + ILogger? logger) + { + if (annotations is null) + { + return; + } + + if (!TryGetCount(annotations, logger, out int count)) + { + omissions[AnnotationsPropertyName] = true; + return; + } + + JsonArray projection = []; + int omittedCount = 0; + for (int index = 0; index < count; index++) + { + if (!TryGetItem(annotations, index, logger, out AIAnnotation? annotation) || + annotation is null) + { + omittedCount++; + continue; + } + + projection.Add((JsonNode)CreateAnnotationProjection(annotation, logger)); + } + + if (projection.Count > 0) + { + envelope[AnnotationsPropertyName] = projection; + } + + if (omittedCount > 0) + { + omissions[AnnotationsPropertyName] = omittedCount; + } + } + + private static JsonObject CreateAnnotationProjection( + AIAnnotation annotation, + ILogger? logger) + { + JsonObject projection = []; + JsonObject omissions = []; + + AddAdditionalProperties( + annotation.AdditionalProperties, + projection, + omissions, + logger); + AddAnnotatedRegions( + annotation.AnnotatedRegions, + projection, + omissions, + logger); + AddRawRepresentation( + annotation.RawRepresentation, + projection, + omissions, + logger); + + if (omissions.Count > 0) + { + projection[OmittedPropertyName] = omissions; + } + + return projection; + } + + private static void AddAdditionalProperties( + AdditionalPropertiesDictionary? additionalProperties, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (additionalProperties is null) + { + return; + } + + KeyValuePair[] entries; + try + { + entries = [.. additionalProperties]; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, additionalProperties, exception); + omissions[AdditionalPropertiesPropertyName] = true; + return; + } + + JsonObject projectedProperties = []; + int omittedCount = 0; + foreach ((string key, object? value) in entries) + { + if (TryConvertToJsonNode(value, logger, out JsonNode? jsonValue)) + { + projectedProperties[key] = jsonValue; + } + else + { + omittedCount++; + } + } + + if (projectedProperties.Count > 0) + { + projection[AdditionalPropertiesPropertyName] = projectedProperties; + } + + if (omittedCount > 0) + { + omissions[AdditionalPropertiesPropertyName] = omittedCount; + } + } + + private static void AddAnnotatedRegions( + IList? annotatedRegions, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (annotatedRegions is null) + { + return; + } + + if (!TryGetCount(annotatedRegions, logger, out int count)) + { + omissions[AnnotatedRegionsPropertyName] = true; + return; + } + + JsonArray projectedRegions = []; + int omittedCount = 0; + for (int index = 0; index < count; index++) + { + if (!TryGetItem(annotatedRegions, index, logger, out AnnotatedRegion? region) || + region is null || + !TryConvertToJsonNode(region, logger, out JsonNode? jsonValue)) + { + omittedCount++; + continue; + } + + projectedRegions.Add(jsonValue); + } + + if (projectedRegions.Count > 0) + { + projection[AnnotatedRegionsPropertyName] = projectedRegions; + } + + if (omittedCount > 0) + { + omissions[AnnotatedRegionsPropertyName] = omittedCount; + } + } + + private static void AddRawRepresentation( + object? rawRepresentation, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (rawRepresentation is null) + { + return; + } + + if (TryConvertToJsonNode(rawRepresentation, logger, out JsonNode? jsonValue)) + { + projection[RawRepresentationPropertyName] = jsonValue; + } + else + { + omissions[RawRepresentationPropertyName] = true; + } + } + + private static bool TryConvertToJsonNode( + object? value, + ILogger? logger, + out JsonNode? jsonValue) + { + try + { + JsonElement element = ToJsonElement(value).Clone(); + jsonValue = ToJsonNode(element); + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, value, exception); + jsonValue = null; + return false; + } + } + + private static bool TryGetCount( + IList values, + ILogger? logger, + out int count) + { + try + { + count = values.Count; + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, values, exception); + count = 0; + return false; + } + } + + private static bool TryGetItem( + IList values, + int index, + ILogger? logger, + out T? value) + { + try + { + value = values[index]; + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, values, exception); + value = default; + return false; + } + } + + private static JsonNode? ToJsonNode(JsonElement element) + { + return JsonNode.Parse(element.GetRawText()); + } + + private static bool TryGetEnvelope( + JsonElement content, + out JsonElement envelope, + out string? kind) + { + envelope = default; + kind = null; + if (content.ValueKind != JsonValueKind.Object || + !HasExactlyOneProperty(content, DurableEnvelopePropertyName) || + !content.TryGetProperty(DurableEnvelopePropertyName, out envelope) || + envelope.ValueKind != JsonValueKind.Object || + !envelope.TryGetProperty(KindPropertyName, out JsonElement kindElement) || + kindElement.ValueKind != JsonValueKind.String || + !envelope.TryGetProperty(VersionPropertyName, out JsonElement versionElement) || + versionElement.ValueKind != JsonValueKind.Number || + !versionElement.TryGetInt32(out int version) || + version != DurableEnvelopeVersion) + { + return false; + } + + kind = kindElement.GetString(); + return kind is not null; + } + + private static bool TryReadUnknownContent( + JsonElement envelope, + out AIContent content) + { + content = null!; + if (!HasOnlyProperties( + envelope, + KindPropertyName, + VersionPropertyName, + AnnotationsPropertyName, + AdditionalPropertiesPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAnnotations(envelope, out List? annotations) || + !TryReadAdditionalProperties( + envelope, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(envelope, out object? rawRepresentation) || + !HasValidOmissions(envelope)) + { + return false; + } + + content = new AIContent + { + Annotations = annotations, + AdditionalProperties = additionalProperties, + RawRepresentation = rawRepresentation, + }; + return true; + } + + private static bool TryReadAnnotations( + JsonElement envelope, + out List? annotations) + { + annotations = null; + if (!envelope.TryGetProperty(AnnotationsPropertyName, out JsonElement annotationsElement)) + { + return true; + } + + if (annotationsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + List result = []; + JsonTypeInfo regionTypeInfo = + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AnnotatedRegion)); + foreach (JsonElement annotationElement in annotationsElement.EnumerateArray()) + { + if (annotationElement.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + annotationElement, + AdditionalPropertiesPropertyName, + AnnotatedRegionsPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAdditionalProperties( + annotationElement, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(annotationElement, out object? rawRepresentation) || + !HasValidOmissions(annotationElement)) + { + return false; + } + + List? regions = null; + if (annotationElement.TryGetProperty( + AnnotatedRegionsPropertyName, + out JsonElement regionsElement)) + { + if (regionsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + regions = []; + foreach (JsonElement regionElement in regionsElement.EnumerateArray()) + { + try + { + if (regionElement.Deserialize(regionTypeInfo) is not AnnotatedRegion region) + { + return false; + } + + regions.Add(region); + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + return false; + } + } + } + + result.Add(new AIAnnotation + { + AdditionalProperties = additionalProperties, + AnnotatedRegions = regions, + RawRepresentation = rawRepresentation, + }); + } + + annotations = result; + return true; + } + + private static bool TryReadAdditionalProperties( + JsonElement envelope, + out AdditionalPropertiesDictionary? additionalProperties) + { + additionalProperties = null; + if (!envelope.TryGetProperty( + AdditionalPropertiesPropertyName, + out JsonElement additionalPropertiesElement)) + { + return true; + } + + if (additionalPropertiesElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + AdditionalPropertiesDictionary result = []; + foreach (JsonProperty property in additionalPropertiesElement.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + additionalProperties = result; + return true; + } + + private static bool TryReadRawRepresentation( + JsonElement envelope, + out object? rawRepresentation) + { + rawRepresentation = null; + if (envelope.TryGetProperty( + RawRepresentationPropertyName, + out JsonElement rawRepresentationElement)) + { + rawRepresentation = rawRepresentationElement.Clone(); + } + + return true; + } + + private static bool HasValidOmissions(JsonElement envelope) + { + if (!envelope.TryGetProperty(OmittedPropertyName, out JsonElement omittedElement)) + { + return true; + } + + if (omittedElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (JsonProperty property in omittedElement.EnumerateObject()) + { + if (property.Name is not ( + AnnotationsPropertyName or + AdditionalPropertiesPropertyName or + RawRepresentationPropertyName or + AnnotatedRegionsPropertyName) || + (property.Value.ValueKind != JsonValueKind.True && + property.Value.ValueKind != JsonValueKind.False && + (property.Value.ValueKind != JsonValueKind.Number || + !property.Value.TryGetInt32(out int count) || + count < 0))) + { + return false; + } + } + + return true; + } + + private static bool HasExactlyOneProperty(JsonElement element, string propertyName) + { + int count = 0; + foreach (JsonProperty property in element.EnumerateObject()) + { + count++; + if (!property.NameEquals(propertyName) || count > 1) + { + return false; + } + } + + return count == 1; + } + + private static bool HasOnlyProperties(JsonElement element, params string[] allowedNames) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!allowedNames.Contains(property.Name, StringComparer.Ordinal)) + { + return false; + } + } + + return true; + } + + private static AIContent CreateOpaqueAIContent(JsonElement content) + { + return new AIContent + { + RawRepresentation = content.Clone(), + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["content"] = content.Clone(), + }, + }; + } + + private static void LogSerializationFallback( + ILogger? logger, + object? value, + Exception exception) + { + if (logger is null) + { + return; + } + + try + { + logger.LogUnknownContentSerializationFallback( + value?.GetType().FullName ?? "null", + GetFailureCategory(exception)); + } + catch (Exception loggingException) when (IsRecoverableSerializationFailure(loggingException)) + { + } + } + + private static string GetFailureCategory(Exception exception) + { + return exception switch + { + ObjectDisposedException => "disposedValue", + JsonException => "invalidJson", + NotSupportedException => "unsupportedType", + InvalidOperationException => "invalidOperation", + ArgumentException => "invalidValue", + FormatException => "invalidFormat", + OverflowException => "numericOverflow", + IOException => "ioFailure", + _ => "customSerializationFailure", + }; + } + + private static bool IsRecoverableSerializationFailure(Exception exception) + { + if (exception is OperationCanceledException or + OutOfMemoryException or + StackOverflowException or + AccessViolationException or + AppDomainUnloadedException or + BadImageFormatException or + CannotUnloadAppDomainException or + InvalidProgramException or + SEHException) + { + return false; + } + + if (exception is AggregateException aggregateException) + { + return aggregateException.InnerExceptions.All(IsRecoverableSerializationFailure); + } - return content ?? throw new InvalidOperationException($"The content '{this.Content}' is not valid AI content."); + return exception.InnerException is null || + IsRecoverableSerializationFailure(exception.InnerException); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs index 8c6bbb8..3090212 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs @@ -20,7 +20,8 @@ internal sealed class DurableAgentStateUriContent : DurableAgentStateContent /// Gets the media type of the content. /// [JsonPropertyName("mediaType")] - public required string MediaType { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MediaType { get; init; } /// /// Creates a from a . @@ -39,6 +40,12 @@ public static DurableAgentStateUriContent FromUriContent(UriContent uriContent) /// public override AIContent ToAIContent() { + if (this.MediaType is null) + { + throw new InvalidOperationException( + "The current .NET UriContent contract cannot represent a URI without a media type."); + } + return new UriContent(this.Uri, this.MediaType); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs index 1b3714f..d5b6e6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs @@ -34,10 +34,17 @@ internal sealed class DurableAgentStateUsage public long? TotalTokenCount { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets provider-specific usage counts from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown usage properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . @@ -51,7 +58,12 @@ usage is not null { InputTokenCount = usage.InputTokenCount, OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount + TotalTokenCount = usage.TotalTokenCount, + ExtensionData = usage.AdditionalCounts?.ToDictionary( + pair => pair.Key, + pair => JsonSerializer.SerializeToElement( + pair.Value, + DurableAgentStateJsonContext.Default.Int64)), } : null; @@ -61,11 +73,31 @@ usage is not null /// A representing this usage. public UsageDetails ToUsageDetails() { + AdditionalPropertiesDictionary? additionalCounts = null; + foreach (IDictionary? values in new[] { this.ExtensionData, this.UnknownProperties }) + { + if (values is null) + { + continue; + } + + foreach ((string name, JsonElement value) in values) + { + if (value.ValueKind == JsonValueKind.Number && + value.TryGetInt64(out long count)) + { + additionalCounts ??= []; + additionalCounts[name] = count; + } + } + } + return new() { InputTokenCount = this.InputTokenCount, OutputTokenCount = this.OutputTokenCount, - TotalTokenCount = this.TotalTokenCount + TotalTokenCount = this.TotalTokenCount, + AdditionalCounts = additionalCounts, }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 58166f0..71727eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -1,12 +1,13 @@ # Durable Agent State -Durable agents are represented as durable entities, with each session of conversation history stored as JSON-serialized state for an individual entity instance. +Durable agents are represented as durable entities, with conversation history stored as JSON-serialized +state for an individual entity instance. ## State Schema The [schema](../../../../schemas/durable-agent-entity-state.json) for durable agent state is a distillation of the prompt and response messages accumulated over the lifetime of a session. While these messages and content originate from Microsoft Agent Framework types (for .NET, see [ChatMessage](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs) and [AIContent](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs)), durable agent state uses its own, parallel, types in order to (1) better manage the versioning and compatibility of serialized state over time, (2) account for agent implementations across languages/platforms (e.g. .NET and Python), as well as (3) ensure consistency for external tools that make use of state data. -> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type can be used when an AI content type is encountered but no equivalent type exists. +> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type is used when an AI content type is encountered but no equivalent type exists. Arbitrary `unknown.content` JSON is opaque: generic producer fields, including `$runtimeType`, are never interpreted by .NET and round-trip unchanged. .NET uses the single namespaced `$microsoftAgentFrameworkDurableTask` property only for its versioned metadata envelope. That envelope contains no runtime type name and can restore only the common `AIContent` contract (`Annotations`, `AdditionalProperties`, and safely serializable `RawRepresentation`); it can never select or construct a CLR type. Common metadata values are converted independently. Unsupported, cyclic, disposed, invalid, or getter/converter-failing values are omitted while safe siblings remain, an omission count/flag is recorded, and a warning logs only the value's type and a fixed failure category. The final durable state therefore contains only JSON-safe data. ## State Versioning @@ -14,16 +15,96 @@ The serialized state contains a root `schemaVersion` property, which represents Some versioning considerations: -- Versions should use semver notation (e.g. `".."`) +- Versions use the strict numeric SemVer core grammar `".."`: exactly three + non-negative decimal components, with no leading zeroes except the single digit `0`. Prerelease + suffixes, build metadata, a `v` prefix, missing/extra components, and whitespace are rejected. - Durable agents should use the version property to determine how to deserialize that state and should not attempt to deserialize semver-incompatible versions - Newer versions of durable agents should strive to be compatible with older schema versions (e.g. new properties and objects should be optional) - Durable agents should preserve existing, but unrecognized, properties when serializing state +Schema version 1.2 adds optional message identity and extension metadata, opaque session state, workflow +`ingestedPositions`, and bounded truncation evidence. The .NET workflow path preserves but does not currently +populate `ingestedPositions`. Older 1.x state remains readable. `DurableAgentState.Clone()` promotes older +supported versions to 1.2 when a caller uses that write-clone path. Versions outside the exact contract +snapshots are rejected until compatibility is explicitly reviewed. Entity execution uses an independent +working-state clone so failed operations do not mutate the hydrated state. New +`DurableAgentState` instances default to the current version, while deserialization preserves the persisted +version through an init-only property. + +The historical 1.0–1.2 message boundaries remain unchanged on both reads and writes: supported roles +are `user`, `assistant`, `system`, and `tool`; function-call arguments are objects when present; URI +content requires a media type. Legacy runtime adapters continue to use typed function arguments, +not string-valued `RawRepresentation`. Developer messages and verbatim string arguments use the +explicit schema-2 adapters only. The production legacy writer validates the serialized transcript +before publishing it, so directly constructed DTOs cannot bypass these boundaries. A failed write +leaves the hydrated state unchanged and does not record completion. + +The schema's declared `extensionData` objects and forward-compatible unknown JSON properties are distinct. +The .NET model names declared metadata `ExtensionData` (or message `AdditionalProperties`) and names +`[JsonExtensionData]` catch-all dictionaries `UnknownProperties`. Both coexist and round-trip independently +at root, data, entry, message, and usage boundaries. Content types also use `UnknownProperties`; the current +schema does not declare a content-level `extensionData` field. Unknown fields are never folded into an +application-defined `extensionData` object. + +Usage `extensionData` is preserved as arbitrary JSON for forward compatibility. When a durable response is +projected to `UsageDetails`, only integral numeric extension values representable as `Int64` become additional +counts; strings, objects, arrays, fractional numbers, and out-of-range numbers remain in durable state but are +ignored by the runtime projection. Malformed known count fields fail deserialization rather than being silently reinterpreted. + +Mailbox lookup, delivery, and guarded entity-local commit behavior are implemented. Session ownership, +replay filtering, transcript compaction, and provider behavior remain deferred to later stack layers. + +## Revised execution-state foundation + +The mailbox contract and optional opaque history profile use schema `2.0.0`. This is intentionally a fail-closed major +version: a 1.x worker preserves unknown fields but does not understand completion receipts, so allowing it to +process revised state could rerun work whose transcript result was already removed. The .NET reader now +supports mailbox-aware 2.0 lookup, while new state continues to default to `1.2.0`. +Producer activation is an internal test-only gate, disabled by default. There is no public opt-in until +the shared contract and deployment floor are agreed; this draft does not authorize production activation. Existing 2.0 state +is preserved rather than downgraded, and committed duplicates remain readable with writes disabled. +New requests against existing 2.0 state fail before agent execution when the internal write gate is disabled. +The internal passive contract path remains available for serializer/fixture tests without authorizing new +production writes. + +In revised state, `terminalResults` stores immutable result envelopes by correlation ID outside +`conversationHistory`, while `completionReceipts` retains completion evidence after a result payload expires. +An `available` receipt requires a matching result; an `unavailable` receipt proves completion without a result +payload while retaining its outcome. Absence of a receipt means only that no terminal completion is recorded; +it does not distinguish an accepted pending request from an unknown identity. Optional `historyBinding` is an +opaque, separately versioned runtime profile. Non-relying consumers preserve it without interpreting any +nested field. Only a relying runtime may validate a profile it recognizes against trusted host configuration; +the shared contract defines no owner kind, provider key, default, or transition policy. +Missing and explicit-null profiles remain distinct, and scalar, array, and object values round-trip +unchanged through the independent working clone. This layer never selects a provider from this data. + +The shared DTOs retain their schema contract. Delivery lookup and polling use the same resolver for both +layouts. `ResultRetentionPeriod` is optional and defaults to no payload expiry; expiry never removes the +completion receipt. Binding selection/enforcement and transcript retention are not implemented by this layer. + +Under the internal schema-2 test gate, one successful durable entity operation atomically stages the +terminal result and receipt together with that operation's session continuation, ingestion bookkeeping, +entity-local transcript, whole-entity TTL, optional binding, and other local control state. External provider +writes and tool side effects are outside that entity-local transaction. A failed outer invocation or durable +commit does not establish a new completion receipt. Legacy conversion uses only retained authoritative +terminal evidence, never calls the model/tools, and cannot invent receipts for already-evicted results. +Previously persisted legacy state stays legacy unless independently authorized as complete history; +retained transcript alone does not establish that authorization. Known truncation/compaction prevents +conversion. New missing-state generations can initialize 2.0 only under the internal gate. +Whole-entity deletion of 2.0 state has a separate internal gate, disabled by default. Stored legacy +deadlines cannot erase receipts, and there is no implicit schema-2 entity TTL. + +Schema 2.0 must not be activated as a cross-language write format until every participating runtime either +implements the mailbox/binding contract or explicitly rejects the new major version. The current C# reader is +fail-closed for unsupported versions and defaults new writes to 1.2. Other runtimes require coordinated version +gating before a 2.0 producer is enabled; preserving unknown fields alone is not sufficient because an +unaware worker could ignore completion receipts and rerun completed work. + ## Sample State ```json { - "schemaVersion": "1.0.0", + "schemaVersion": "1.2.0", "data": { "conversationHistory": [ { diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index 4844702..f1ece28 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -24,7 +24,8 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; /// backed by Durable Entities), a request port (human-in-the-loop, backed by external events), /// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the /// appropriate Durable Task API. -/// The serialised string result is returned to the runner for the routing phase. +/// Framework-owned output is returned to the runner for the routing phase. Only the +/// regular activity boundary interprets serialized executor control fields. /// internal static class DurableExecutorDispatcher { @@ -38,7 +39,7 @@ internal static class DurableExecutorDispatcher /// The live workflow status used to publish events and pending request port state. /// The logger for tracing. /// The result from the executor. - internal static async Task DispatchAsync( + internal static async Task DispatchAsync( TaskOrchestrationContext context, WorkflowExecutorInfo executorInfo, DurableMessageEnvelope envelope, @@ -66,7 +67,7 @@ internal static async Task DispatchAsync( return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true); } - private static async Task ExecuteActivityAsync( + private static async Task ExecuteActivityAsync( TaskOrchestrationContext context, WorkflowExecutorInfo executorInfo, string input, @@ -85,7 +86,8 @@ private static async Task ExecuteActivityAsync( string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput); - return await context.CallActivityAsync(activityName, serializedInput).ConfigureAwait(true); + string result = await context.CallActivityAsync(activityName, serializedInput).ConfigureAwait(true); + return DurableExecutorOutput.FromActivityResult(result); } /// @@ -101,7 +103,7 @@ private static async Task ExecuteActivityAsync( /// The wait has no built-in timeout; for time-limited approvals, callers can combine /// context.CreateTimer with Task.WhenAny in a wrapper executor. /// - private static async Task ExecuteRequestPortAsync( + private static async Task ExecuteRequestPortAsync( TaskOrchestrationContext context, WorkflowExecutorInfo executorInfo, string input, @@ -129,26 +131,7 @@ private static async Task ExecuteRequestPortAsync( logger.LogReceivedExternalEvent(eventName); - return CreateExecutorOutputEnvelope(response); - } - - /// - /// Instead of blindly taking the incoming JSON to produce the output of the executor, - /// builds a -compatible JSON envelope where only - /// the result property is set from the response value. - /// Other properties are serialized with their defaults (empty collections). - /// This prevents the incoming JSON payload from inadvertently populating other properties - /// of during deserialization. - /// - /// - /// For input {"Approved":true,"Comments":"ok"}, produces: - /// {"result":"{\"Approved\":true,\"Comments\":\"ok\"}","stateUpdates":{},"clearedScopes":[],"events":[],"sentMessages":[]} - /// - internal static string CreateExecutorOutputEnvelope(string response) - { - return JsonSerializer.Serialize( - new DurableExecutorOutput { Result = response }, - DurableWorkflowJsonContext.Default.DurableExecutorOutput); + return new DurableExecutorOutput { Result = response }; } /// @@ -158,7 +141,7 @@ internal static string CreateExecutorOutputEnvelope(string response) /// AI agents are stateful and maintain conversation history. They use Durable Entities /// to persist state across orchestration replays. /// - private static async Task ExecuteAgentAsync( + private static async Task ExecuteAgentAsync( TaskOrchestrationContext context, WorkflowExecutorInfo executorInfo, ILogger logger, @@ -170,13 +153,13 @@ private static async Task ExecuteAgentAsync( if (agent is null) { logger.LogAgentNotFound(agentName); - return $"Agent '{agentName}' not found"; + return new DurableExecutorOutput { Result = $"Agent '{agentName}' not found" }; } AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(true); AgentResponse response = await agent.RunAsync(input, session).ConfigureAwait(true); - return response.Text; + return new DurableExecutorOutput { Result = response.Text }; } /// @@ -191,7 +174,7 @@ private static async Task ExecuteAgentAsync( /// which this method converts to a so the parent /// workflow's result processing picks up both the result and any accumulated events. /// - private static async Task ExecuteSubWorkflowAsync( + private static async Task ExecuteSubWorkflowAsync( TaskOrchestrationContext context, WorkflowExecutorInfo executorInfo, string input) @@ -209,15 +192,15 @@ private static async Task ExecuteSubWorkflowAsync( /// /// Converts a from a sub-orchestration - /// into a JSON string. This bridges the sub-workflow's + /// into a . This bridges the sub-workflow's /// output format to the parent workflow's result processing, preserving both the result /// and any accumulated events from the sub-workflow. /// - private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResult? workflowResult) + private static DurableExecutorOutput ConvertWorkflowResultToExecutorOutput(DurableWorkflowResult? workflowResult) { if (workflowResult is null) { - return string.Empty; + return new DurableExecutorOutput { Result = string.Empty }; } // Propagate the result, events, and sent messages from the sub-workflow. @@ -225,14 +208,12 @@ private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResul // matching the in-process WorkflowHostExecutor behavior. // Shared state is not included because each workflow instance maintains its own // independent shared state; it is not shared between parent and sub-workflows. - DurableExecutorOutput executorOutput = new() + return new DurableExecutorOutput { Result = workflowResult.Result, Events = workflowResult.Events ?? [], SentMessages = workflowResult.SentMessages ?? [], HaltRequested = workflowResult.HaltRequested, }; - - return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs index ce3f26c..491b516 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; + namespace Microsoft.Agents.AI.DurableTask.Workflows; /// @@ -7,6 +9,11 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; /// internal sealed class DurableExecutorOutput { + private static readonly string[] s_outputProperties = + ["result", "stateUpdates", "clearedScopes", "events", "sentMessages", "haltRequested"]; + + private static readonly string[] s_messageProperties = ["typeName", "data"]; + /// /// Gets the executor result. /// @@ -36,4 +43,106 @@ internal sealed class DurableExecutorOutput /// Gets a value indicating whether the executor requested a workflow halt. /// public bool HaltRequested { get; init; } + + /// + /// Reads controls only from a trusted activity response. Legacy text and invalid + /// envelopes remain opaque results; no controls from a partially valid envelope are applied. + /// + internal static DurableExecutorOutput FromActivityResult(string rawResult) + { + if (!string.IsNullOrEmpty(rawResult)) + { + try + { + using JsonDocument document = JsonDocument.Parse(rawResult); + if (HasUnambiguousProperties(document.RootElement, s_outputProperties, out HashSet presentProperties)) + { + DurableExecutorOutput? output = document.RootElement.Deserialize( + DurableWorkflowJsonContext.Default.DurableExecutorOutput); + + if (output is not null && HasValidCollections(output, presentProperties) && HasMeaningfulContent(output)) + { + bool validMessages = true; + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + if (property.Name.Equals("sentMessages", StringComparison.OrdinalIgnoreCase)) + { + validMessages = property.Value.EnumerateArray().All( + message => HasUnambiguousProperties(message, s_messageProperties, out _)); + } + } + + if (validMessages) + { + // Source-generated deserialization overwrites omitted init-only collections with null. + // Explicit null properties were rejected above; only missing collections use defaults. + return new DurableExecutorOutput + { + Result = output.Result, + StateUpdates = output.StateUpdates ?? [], + ClearedScopes = output.ClearedScopes ?? [], + Events = output.Events ?? [], + SentMessages = output.SentMessages ?? [], + HaltRequested = output.HaltRequested, + }; + } + } + } + } + catch (JsonException) + { + // An invalid known field rejects the entire envelope, including otherwise valid controls. + } + } + + return new DurableExecutorOutput { Result = rawResult }; + } + + private static bool HasUnambiguousProperties( + JsonElement element, + string[] knownProperties, + out HashSet presentProperties) + { + presentProperties = new(StringComparer.OrdinalIgnoreCase); + if (element.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (JsonProperty property in element.EnumerateObject()) + { + if (knownProperties.Contains(property.Name, StringComparer.OrdinalIgnoreCase) && !presentProperties.Add(property.Name)) + { + return false; + } + } + + return true; + } + + private static bool HasValidCollections(DurableExecutorOutput output, HashSet presentProperties) + { + return (output.StateUpdates is not null || !presentProperties.Contains(nameof(StateUpdates))) + && IsValidList(output.ClearedScopes, presentProperties, nameof(ClearedScopes)) + && IsValidList(output.Events, presentProperties, nameof(Events)) + && IsValidList(output.SentMessages, presentProperties, nameof(SentMessages)); + } + + private static bool IsValidList(List? values, HashSet presentProperties, string propertyName) + where T : class + { + return values is null + ? !presentProperties.Contains(propertyName) + : values.All(value => value is not null); + } + + private static bool HasMeaningfulContent(DurableExecutorOutput output) + { + return output.Result is not null + || output.SentMessages?.Count > 0 + || output.Events?.Count > 0 + || output.StateUpdates?.Count > 0 + || output.ClearedScopes?.Count > 0 + || output.HaltRequested; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs index c4685de..d2eee8b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs @@ -192,7 +192,7 @@ private static async Task RunSuperstepLoopAsync( logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId))); } - string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true); + DurableExecutorOutput[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true); haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger); @@ -243,13 +243,13 @@ private static int CountRemainingExecutors(Dictionary kvp.Value.Count > 0); } - private static async Task DispatchExecutorsInParallelAsync( + private static async Task DispatchExecutorsInParallelAsync( TaskOrchestrationContext context, List executorInputs, SuperstepState state, ILogger logger) { - Task[] dispatchTasks = executorInputs + Task[] dispatchTasks = executorInputs .Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, state.SharedState, state.LiveStatus, logger)) .ToArray(); @@ -391,7 +391,7 @@ private static DurableMessageEnvelope AggregateQueueMessages( /// true if a halt was requested by any executor; otherwise, false. private static bool ProcessSuperstepResults( List inputs, - string[] rawResults, + DurableExecutorOutput[] results, SuperstepState state, TaskOrchestrationContext context, ILogger logger) @@ -401,11 +401,12 @@ private static bool ProcessSuperstepResults( for (int i = 0; i < inputs.Count; i++) { string executorId = inputs[i].ExecutorId; - ExecutorResultInfo resultInfo = ParseActivityResult(rawResults[i]); + DurableExecutorOutput resultInfo = results[i]; + string result = resultInfo.Result ?? string.Empty; - logger.LogExecutorResultReceived(executorId, resultInfo.Result.Length, resultInfo.SentMessages.Count); + logger.LogExecutorResultReceived(executorId, result.Length, resultInfo.SentMessages.Count); - state.LastResults[executorId] = resultInfo.Result; + state.LastResults[executorId] = result; // Merge state updates from activity into shared state MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes); @@ -426,7 +427,7 @@ private static bool ProcessSuperstepResults( PublishEventsToLiveStatus(context, state); } - RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger); + RouteOutputToSuccessors(executorId, result, resultInfo.SentMessages, state, logger); } return haltRequested; @@ -719,68 +720,4 @@ private static string GetFinalResult(Dictionary lastResults) { return lastResults.Values.LastOrDefault(value => !string.IsNullOrEmpty(value)) ?? string.Empty; } - - /// - /// Output from an executor invocation, including its result, - /// messages, state updates, and emitted workflow events. - /// - private sealed record ExecutorResultInfo( - string Result, - List SentMessages, - Dictionary StateUpdates, - List ClearedScopes, - List Events, - bool HaltRequested); - - /// - /// Parses the raw activity result to extract result, messages, events, and state updates. - /// - private static ExecutorResultInfo ParseActivityResult(string rawResult) - { - if (string.IsNullOrEmpty(rawResult)) - { - return new ExecutorResultInfo(rawResult, [], [], [], [], false); - } - - try - { - DurableExecutorOutput? output = JsonSerializer.Deserialize( - rawResult, - DurableWorkflowJsonContext.Default.DurableExecutorOutput); - - if (output is null || !HasMeaningfulContent(output)) - { - return new ExecutorResultInfo(rawResult, [], [], [], [], false); - } - - return new ExecutorResultInfo( - output.Result ?? string.Empty, - output.SentMessages, - output.StateUpdates, - output.ClearedScopes, - output.Events, - output.HaltRequested); - } - catch (JsonException) - { - return new ExecutorResultInfo(rawResult, [], [], [], [], false); - } - } - - /// - /// Determines whether the activity output contains meaningful content. - /// - /// - /// Distinguishes actual activity output from arbitrary JSON that deserialized - /// successfully but with all default/empty values. - /// - private static bool HasMeaningfulContent(DurableExecutorOutput output) - { - return output.Result is not null - || output.SentMessages?.Count > 0 - || output.Events?.Count > 0 - || output.StateUpdates?.Count > 0 - || output.ClearedScopes?.Count > 0 - || output.HaltRequested; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 968176b..7ce2281 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -3,8 +3,10 @@ using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Http.Headers; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using Azure.Core.Serialization; using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Azure.Functions.Worker; @@ -15,6 +17,7 @@ using Microsoft.DurableTask.Worker.Grpc; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Microsoft.Agents.AI.Hosting.AzureFunctions; @@ -52,6 +55,7 @@ internal static class BuiltInFunctions private const string SessionIdHeaderName = "x-ms-session-id"; private const string SessionIdParameterName = "session_id"; private const string SessionIdMcpArgumentName = "sessionId"; + private const string ResponseFormatMcpArgumentName = "responseFormat"; /// /// Deprecated alias for . Still accepted on incoming requests, @@ -406,11 +410,40 @@ public static async Task RunAgentHttpAsync( if (waitForResponse) { - AgentResponse agentResponse = await agentProxy.RunAsync( - message: new ChatMessage(ChatRole.User, message), - session: new DurableAgentSession(sessionId), - options: options, - cancellationToken: context.CancellationToken); + AgentResponse agentResponse; + try + { + agentResponse = await agentProxy.RunAsync( + message: new ChatMessage(ChatRole.User, message), + session: new DurableAgentSession(sessionId), + options: options, + cancellationToken: context.CancellationToken); + } + catch (DurableAgentResultUnavailableException exception) + { + return await CreateAgentOutcomeErrorResponseAsync( + req, + context, + HttpStatusCode.Gone, + sessionId.Key, + "completedResultUnavailable", + "resultUnavailable", + exception.Message, + details: null, + completionOutcome: exception.Outcome); + } + catch (DurableAgentTerminalException exception) + { + return await CreateAgentOutcomeErrorResponseAsync( + req, + context, + HttpStatusCode.InternalServerError, + sessionId.Key, + "failed", + exception.Code ?? "terminalFailure", + exception.Message, + exception.Details); + } return await CreateSuccessResponseAsync( req, @@ -448,6 +481,17 @@ await agentProxy.RunAsync( throw new ArgumentException("MCP Tool invocation is missing required 'query' argument of type string."); } + bool returnJson = false; + if (context.Arguments.TryGetValue(ResponseFormatMcpArgumentName, out object? responseFormat)) + { + if (responseFormat is not string format || (format != "text" && format != "json")) + { + throw new ArgumentException("MCP Tool 'responseFormat' must be 'text' or 'json'."); + } + + returnJson = format == "json"; + } + string agentName = context.Name; // Bind the caller-supplied session key under the current agent name, mirroring the behavior of @@ -474,9 +518,24 @@ await agentProxy.RunAsync( AgentResponse agentResponse = await agentProxy.RunAsync( message: new ChatMessage(ChatRole.User, query), session: new DurableAgentSession(sessionId), - options: null); + options: null, + cancellationToken: functionContext.CancellationToken); + + if (!returnJson) + { + return agentResponse.Text; + } - return agentResponse.Text; + ObjectSerializer serializer = functionContext.InstanceServices + .GetRequiredService>().Value.Serializer + ?? throw new InvalidOperationException("The Functions worker JSON serializer is not configured."); + using MemoryStream stream = new(); + await serializer.SerializeAsync( + stream, + new AgentRunSuccessResponse((int)HttpStatusCode.OK, sessionId.Key, agentResponse), + typeof(AgentRunSuccessResponse), + functionContext.CancellationToken); + return Encoding.UTF8.GetString(stream.ToArray()); } /// @@ -756,6 +815,44 @@ private static async Task CreateAcceptedResponseAsync( return response; } + private static async Task CreateAgentOutcomeErrorResponseAsync( + HttpRequestData req, + FunctionContext context, + HttpStatusCode statusCode, + string sessionId, + string outcome, + string code, + string message, + JsonElement? details, + string? completionOutcome = null) + { + HttpResponseData response = req.CreateResponse(statusCode); + response.Headers.Add(SessionIdHeaderName, sessionId); + if (completionOutcome is not null) + { + response.Headers.Add("x-ms-agent-completion-outcome", completionOutcome); + } + + if (AcceptsJson(req)) + { + await response.WriteAsJsonAsync( + new AgentRunFailureResponse( + (int)statusCode, + sessionId, + outcome, + new AgentRunError(code, message, details), + completionOutcome), + context.CancellationToken); + } + else + { + response.Headers.Add("Content-Type", "text/plain"); + await response.WriteStringAsync(message, context.CancellationToken); + } + + return response; + } + /// /// Returns when the caller has requested waiting for the workflow/agent to complete, /// as indicated by the x-ms-wait-for-response header or query parameter. @@ -1075,7 +1172,12 @@ private sealed record ErrorResponse( internal sealed record AgentRunSuccessResponse( [property: JsonPropertyName("status")] int Status, [property: JsonPropertyName("session_id")] string SessionId, - [property: JsonPropertyName("response")] AgentResponse Response); + [property: JsonPropertyName("response")] AgentResponse Response) + { + [JsonPropertyName("result")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Result => this.Response.GetDurableResult(); + } /// /// Represents an accepted (fire-and-forget) agent run response. @@ -1086,6 +1188,22 @@ internal sealed record AgentRunAcceptedResponse( [property: JsonPropertyName("status")] int Status, [property: JsonPropertyName("session_id")] string SessionId); + internal sealed record AgentRunFailureResponse( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("session_id")] string SessionId, + [property: JsonPropertyName("outcome")] string Outcome, + [property: JsonPropertyName("error")] AgentRunError Error, + [property: JsonPropertyName("completion_outcome")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? CompletionOutcome = null); + + internal sealed record AgentRunError( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("details")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + JsonElement? Details); + /// /// Represents a request to respond to a pending RequestPort in a workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 46adaf9..dd162c0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Surface durable mailbox terminal failures and unavailable results without successful fallback responses, preserve completion outcome metadata, and add opt-in full-result MCP responses while retaining legacy text output ([#94](https://github.com/microsoft/agent-framework-durable-extension/pull/94)) - Fixed the durable configuration methods not composing on the same application: calling `ConfigureDurableAgents` first left the workflow functions without an executor, calling `ConfigureDurableWorkflows` first registered the built-in function execution middleware twice, agents registered through `ConfigureDurableOptions` generated no functions at all, leaving the agent silently unreachable, and registering an agent that a workflow already referenced threw instead of promoting it. Agents now get the same entry points regardless of which method registers them and in which order, with each function generated exactly once even when a workflow and an explicit registration both contribute the same agent, while agents that exist only because a workflow references them continue to get no HTTP endpoint of their own ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67)) - [BREAKING] Always return JSON from the workflow status and respond endpoints, including on errors and when the request sends no `Accept` header, and fix malformed request bodies surfacing as an unhandled error instead of `400 Bad Request` ([#60](https://github.com/microsoft/agent-framework-durable-extension/pull/60)) - [BREAKING] Support bounded synchronous workflow HTTP invocation through query parameters and default workflow run responses to JSON, with `Accept: text/plain` available for the legacy text format and the same negotiated asynchronous response returned on timeout ([#52](https://github.com/microsoft/agent-framework-durable-extension/pull/52)) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs index 84dca5c..b3469bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs @@ -98,9 +98,10 @@ private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, st Language = "dotnet-isolated", RawBindings = [ - $$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{agentName}}","description":"{{description}}","toolProperties":"[{\"propertyName\":\"query\",\"propertyType\":\"string\",\"description\":\"The query to send to the agent.\",\"isRequired\":true,\"isArray\":false},{\"propertyName\":\"sessionId\",\"propertyType\":\"string\",\"description\":\"Optional session identifier.\",\"isRequired\":false,\"isArray\":false}]"}""", + $$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{agentName}}","description":"{{description}}","toolProperties":"[{\"propertyName\":\"query\",\"propertyType\":\"string\",\"description\":\"The query to send to the agent.\",\"isRequired\":true,\"isArray\":false},{\"propertyName\":\"sessionId\",\"propertyType\":\"string\",\"description\":\"Optional session identifier.\",\"isRequired\":false,\"isArray\":false},{\"propertyName\":\"responseFormat\",\"propertyType\":\"string\",\"description\":\"Optional response format: text (default) or json (full result metadata).\",\"isRequired\":false,\"isArray\":false}]"}""", """{"name":"query","type":"mcpToolProperty","direction":"In","propertyName":"query","description":"The query to send to the agent","isRequired":true,"dataType":"String","propertyType":"string"}""", """{"name":"sessionId","type":"mcpToolProperty","direction":"In","propertyName":"sessionId","description":"The session identifier.","isRequired":false,"dataType":"String","propertyType":"string"}""", + """{"name":"responseFormat","type":"mcpToolProperty","direction":"In","propertyName":"responseFormat","description":"Response format: text (default) or json (full result metadata).","isRequired":false,"dataType":"String","propertyType":"string"}""", """{"name":"client","type":"durableClient","direction":"In"}""" ], EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md index 8bfbfdc..97c53da 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md @@ -61,6 +61,36 @@ app.Run(); By default, each agent can be invoked via a built-in HTTP trigger function at the route `http[s]://[host]/api/agents/{agentName}/run`. +### Agent completion outcomes + +HTTP fire-and-forget calls return `202 Accepted`; this acknowledges dispatch, not successful execution. +Calls that wait return `200` only for an available successful result. With `Accept: application/json`, +the native `response` shape remains compatible, and the additive `result` contains canonical retained +terminal-response JSON. Use `result` for optional `value` (absent and explicit null remain distinct), +opaque content, and unknown metadata that the native response cannot represent. Legacy plain-text +negotiation remains supported. + +A supported committed terminal failure returns `500` with `outcome: "failed"` and the retained error +code/details. A completion whose result is unavailable returns `410 Gone` with +`outcome: "completedResultUnavailable"` and the retained `completion_outcome` (`"succeeded"` or `"failed"`); +the `x-ms-agent-completion-outcome` header also carries that outcome for plain-text callers. It must not be treated as +pending, retried with a new identity automatically, or replaced by a transcript-derived result. +Ordinary transient failures and cancellation are not durable terminal outcomes. + +MCP agent calls continue waiting while pending and return text by default for compatibility. +Set the optional tool argument `responseFormat` to `"json"` to receive the full successful response +envelope (`status`, `session_id`, `response`, and canonical `result`); `"text"` explicitly selects legacy text. +Unsupported format values are rejected before dispatch. Committed failures and unavailable results +throw `DurableAgentTerminalException` and `DurableAgentResultUnavailableException` respectively, for +the MCP host to report as tool failures. They are never returned as successful text or JSON responses. +Cancellation is propagated to the durable client. + +These endpoints use the same mailbox-aware client as `AgentRunHandle` and durable proxies. This does +not authorize schema 2.0 writes: the [shared rollout gates](../Microsoft.Agents.AI.DurableTask/State/README.md) +must be satisfied before a producer can be activated; this draft has only disabled internal test gates. +Existing legacy response-format choices do not change +the entity-state version. + ### Orchestrating hosted agents This package also provides a set of extension methods such as `GetAgent` on the [`TaskOrchestrationContext`](https://learn.microsoft.com/dotnet/api/microsoft.durabletask.taskorchestrationcontext) class for interacting with hosted agents within orchestrations. diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs new file mode 100644 index 0000000..ffe5d61 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs @@ -0,0 +1,1146 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +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 AgentEntityDeliveryTests +{ + [Fact] + public async Task DefaultRolloutLeavesProductionWritesLegacyAsync() + { + DurableAgentsOptions defaults = new(); + Assert.False(defaults.EnableMailboxWrites); + Assert.Null(defaults.ResultRetentionPeriod); + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), new DurableAgentState(), enableMailboxWrites: false); + + await harness.RunAsync(new RunRequest("request") { CorrelationId = "new" }); + + DurableAgentState committed = Assert.IsType(harness.PersistedState); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, committed.SchemaVersion); + Assert.Null(committed.Data.TerminalResults); + Assert.Null(committed.Data.CompletionReceipts); + } + + [Theory] + [InlineData("1.0.0", false)] + [InlineData("1.0.0", true)] + [InlineData("1.1.0", false)] + [InlineData("1.1.0", true)] + [InlineData("1.2.0", false)] + [InlineData("1.2.0", true)] + public async Task LegacyDeveloperMessageFailureLeavesStateUnchangedAndRetryableAsync( + string schemaVersion, + bool inResponse) + { + DurableAgentState state = new() { SchemaVersion = schemaVersion }; + state.Data.ConversationHistory.Add(CreateResponse("old", "retained")); + string before = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + int factoryCalls = 0; + RecordingAgent agent = new("agent") + { + ResponseUpdate = new AgentResponseUpdate( + inResponse ? new ChatRole("developer") : ChatRole.Assistant, "response"), + }; + EntityHarness harness = CreateHarness(agent, state, enableMailboxWrites: false, + registerWithFactory: true, onFactoryInvoked: () => factoryCalls++); + RunRequest request = new([ + new ChatMessage(inResponse ? ChatRole.User : new ChatRole("developer"), "request"), + ]) + { + CorrelationId = "new", + }; + + await Assert.ThrowsAsync(() => harness.RunAsync(request)); + + Assert.Equal(inResponse ? 1 : 0, factoryCalls); + Assert.Equal(inResponse ? 1 : 0, agent.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(before, JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState)); + Assert.Null(state.Data.CompletionReceipts); + Assert.Equal(DurableAgentRunOutcomeKind.Pending, + DurableAgentStateOutcomeResolver.Resolve(state, "new", DateTimeOffset.UtcNow).Kind); + + EntityHarness retry = CreateHarness(new RecordingAgent("agent"), state, enableMailboxWrites: false); + Assert.Equal("response", (await retry.RunAsync(new RunRequest("corrected") { CorrelationId = "new" })).Text); + DurableAgentState committed = Reload(Assert.IsType(retry.PersistedState)); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, committed.SchemaVersion); + Assert.Equal(3, committed.Data.ConversationHistory.Count); + Assert.Null(committed.Data.TerminalResults); + Assert.Null(committed.Data.CompletionReceipts); + } + + [Theory] + [InlineData("1.0.0", false)] + [InlineData("1.0.0", true)] + [InlineData("1.1.0", false)] + [InlineData("1.1.0", true)] + [InlineData("1.2.0", false)] + [InlineData("1.2.0", true)] + public async Task LegacyRequestAndResponseKeepHistoricalFunctionArgumentMappingAsync( + string schemaVersion, + bool rawOnly) + { + FunctionCallContent call = new("call", "function", + rawOnly ? null : new Dictionary { ["value"] = false }) + { + RawRepresentation = " { \"incomplete\": ", + }; + RecordingAgent agent = new("agent") + { + ResponseUpdate = new AgentResponseUpdate(ChatRole.Assistant, [call]), + }; + EntityHarness harness = CreateHarness(agent, + new DurableAgentState { SchemaVersion = schemaVersion }, enableMailboxWrites: false); + + await harness.RunAsync(new RunRequest([new ChatMessage(ChatRole.User, [call])]) { CorrelationId = "new" }); + + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, committed.SchemaVersion); + Assert.Null(committed.Data.CompletionReceipts); + foreach (DurableAgentStateEntry entry in committed.Data.ConversationHistory) + { + DurableAgentStateFunctionCallContent stored = Assert.IsType( + Assert.Single(Assert.Single(entry.Messages).Contents)); + Assert.Equal(rawOnly ? JsonValueKind.Undefined : JsonValueKind.Object, stored.Arguments.ValueKind); + if (!rawOnly) + { + Assert.False(stored.Arguments.GetProperty("value").GetBoolean()); + } + } + + Assert.Null(Assert.IsType(Assert.Single(Assert.Single(agent.LastMessages).Contents)).RawRepresentation); + } + + [Theory] + [InlineData("")] + [InlineData(" { \"incomplete\": ")] + [InlineData("""{"halt":true,"state":{"value":0}}""")] + public async Task MailboxRequestResponseAndDuplicatePreserveV2OnlyShapesAsync(string arguments) + { + FunctionCallContent call = new("call", "function") { RawRepresentation = arguments }; + RecordingAgent agent = new("agent") + { + ResponseUpdate = new AgentResponseUpdate(new ChatRole("developer"), [call]), + }; + EntityHarness harness = CreateHarness(agent, state: null, authorizeLegacyMigration: false); + + AgentResponse response = await harness.RunAsync(new RunRequest([ + new ChatMessage(new ChatRole("developer"), [call]), + ]) + { + CorrelationId = "new", + }); + + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + Assert.Equal(DurableAgentState.RevisedSchemaVersion, committed.SchemaVersion); + Assert.Single(committed.Data.CompletionReceipts!); + foreach (DurableAgentStateEntry entry in committed.Data.ConversationHistory) + { + DurableAgentStateMessage message = Assert.Single(entry.Messages); + Assert.Equal("developer", message.Role); + Assert.Equal(arguments, + Assert.IsType(Assert.Single(message.Contents)).Arguments.GetString()); + } + + Assert.Equal(arguments, + Assert.IsType(Assert.Single(Assert.Single(agent.LastMessages).Contents)).RawRepresentation); + JsonElement originalResult = Assert.IsType(response.GetDurableResult()); + Assert.Equal(arguments, originalResult.GetProperty("messages")[0].GetProperty("contents")[0].GetProperty("arguments").GetString()); + + committed.Data.ConversationHistory.Clear(); + EntityHarness duplicate = CreateHarness(new RecordingAgent("agent"), Reload(committed), + enableMailboxWrites: false, registerWithFactory: true, + onFactoryInvoked: () => throw new InvalidOperationException("duplicate must bypass factory")); + AgentResponse retained = await duplicate.RunAsync(new RunRequest([]) { CorrelationId = "new" }); + Assert.True(JsonElement.DeepEquals(originalResult, Assert.IsType(retained.GetDurableResult()))); + } + + [Fact] + public async Task NewMailboxCommitAndPrunedDuplicatePreserveLosslessSharedResultAsync() + { + DurableAgentState state = ReadFixture("shared-durable-agent-state-2.0-lossless.json"); + JsonElement originalResult = Assert.IsType(DurableAgentStateOutcomeResolver + .Resolve(state, "corr-lossless", DateTimeOffset.UtcNow).Response!.GetDurableResult()); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state); + + await harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" }); + + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + Assert.Single(state.Data.CompletionReceipts!); + Assert.Equal(2, committed.Data.CompletionReceipts!.Count); + committed.Data.ConversationHistory.Clear(); + EntityHarness duplicate = CreateHarness(new RecordingAgent("agent"), Reload(committed), + enableMailboxWrites: false, registerWithFactory: true, + onFactoryInvoked: () => throw new InvalidOperationException("duplicate must bypass factory")); + + AgentResponse response = await duplicate.RunAsync(new RunRequest([]) { CorrelationId = "corr-lossless" }); + + JsonElement retained = Assert.IsType(response.GetDurableResult()); + Assert.True(JsonElement.DeepEquals(originalResult, retained)); + Assert.False(retained.GetProperty("messages")[0].GetProperty("contents")[1].TryGetProperty("mediaType", out _)); + Assert.Equal(JsonValueKind.False, retained.GetProperty("value").ValueKind); + } + + [Fact] + public async Task NewMissingStateGenerationCanInitializeMailboxOnlyUnderInternalGateAsync() + { + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state: null, + authorizeLegacyMigration: false); + + await harness.RunAsync(new RunRequest("request") { CorrelationId = "new" }); + + DurableAgentState state = Assert.IsType(harness.PersistedState); + Assert.Equal(DurableAgentState.RevisedSchemaVersion, state.SchemaVersion); + Assert.Single(state.Data.CompletionReceipts!); + Assert.Null(state.Data.ExpirationTimeUtc); + } + + [Fact] + public async Task RetainedLegacyEvidenceDoesNotAuthorizeMigrationAsync() + { + DurableAgentState state = CreateStateWithResponse("old", "retained"); + RecordingAgent agent = new("agent"); + EntityHarness duplicate = CreateHarness(agent, state, authorizeLegacyMigration: false); + + Assert.Equal("retained", (await duplicate.RunAsync(new RunRequest([]) { CorrelationId = "old" })).Text); + Assert.Equal(0, agent.InvocationCount); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, + Assert.IsType(duplicate.PersistedState).SchemaVersion); + + EntityHarness next = CreateHarness(agent, state, authorizeLegacyMigration: false); + await next.RunAsync(new RunRequest("request") { CorrelationId = "new" }); + DurableAgentState committed = Assert.IsType(next.PersistedState); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, committed.SchemaVersion); + Assert.Null(committed.Data.CompletionReceipts); + } + + [Fact] + public async Task PreviouslyPersistedEmptyLegacyStateDoesNotBecomeEmptyMailboxAsync() + { + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), new DurableAgentState(), + authorizeLegacyMigration: false); + + await harness.RunAsync(new RunRequest("request") { CorrelationId = "new" }); + + DurableAgentState committed = Assert.IsType(harness.PersistedState); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, committed.SchemaVersion); + Assert.Null(committed.Data.CompletionReceipts); + } + + [Fact] + public void MailboxActivationAndDeletionHaveNoPublicOptInOrImplicitTtl() + { + DurableAgentsOptions options = new(); + Assert.Null(typeof(DurableAgentsOptions).GetProperty("EnableMailboxWrites")); + Assert.Null(typeof(DurableAgentsOptions).GetProperty("EnableMailboxEntityDeletion")); + Assert.Equal(TimeSpan.FromDays(14), options.GetTimeToLive("agent")); + Assert.Null(options.GetTimeToLive("agent", revisedState: true)); + options.EnableMailboxEntityDeletion = true; + Assert.Null(options.GetTimeToLive("agent", revisedState: true)); + options.DefaultTimeToLive = TimeSpan.FromMinutes(10); + Assert.Equal(TimeSpan.FromMinutes(10), options.GetTimeToLive("agent", revisedState: true)); + } + + [Fact] + public async Task DisabledRolloutRejectsNewRevisedExecutionButAllowsDuplicateAsync() + { + RecordingAgent agent = new("agent"); + DurableAgentState state = CreateRevisedState("old", "retained"); + EntityHarness harness = CreateHarness(agent, state, enableMailboxWrites: false); + + Assert.Equal("retained", (await harness.RunAsync(new RunRequest([]) { CorrelationId = "old" })).Text); + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + Assert.Equal(0, agent.InvocationCount); + Assert.Single(state.Data.CompletionReceipts!); + } + + [Fact] + public async Task PreCancelledRequestBypassesFactoryAndLeavesStateRetryableAsync() + { + int factoryCalls = 0; + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + DurableAgentState state = new(); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, + registerWithFactory: true, onFactoryInvoked: () => factoryCalls++, + cancellationToken: cancellation.Token); + + await Assert.ThrowsAnyAsync( + () => harness.RunAsync(new RunRequest("request") { CorrelationId = "new" })); + + Assert.Equal(0, factoryCalls); + Assert.False(harness.StateWasPersisted); + Assert.Empty(state.Data.ConversationHistory); + } + + [Fact] + public async Task CommitCapacityFailureRollsBackAndCorrelationCanRetryAsync() + { + RecordingAgent agent = new("agent"); + DurableAgentState state = new(); + RunRequest request = new("request") { CorrelationId = "new" }; + EntityHarness failing = CreateHarness(agent, state, + onCommit: _ => throw new InvalidOperationException("backend capacity exceeded")); + + await Assert.ThrowsAsync(() => failing.RunAsync(request)); + + Assert.False(failing.StateWasPersisted); + Assert.Empty(state.Data.ConversationHistory); + Assert.Null(state.Data.CompletionReceipts); + EntityHarness retry = CreateHarness(agent, state); + await retry.RunAsync(request); + Assert.Equal(2, agent.InvocationCount); + Assert.Single(Assert.IsType(retry.PersistedState).Data.CompletionReceipts!); + } + + [Fact] + public async Task ProviderFailureCanRetryWithoutCreatingFailedReceiptAsync() + { + RecordingAgent agent = new("agent") { Exception = new InvalidOperationException("provider unavailable") }; + DurableAgentState state = new(); + RunRequest request = new("request") { CorrelationId = "new" }; + EntityHarness first = CreateHarness(agent, state); + + await Assert.ThrowsAsync(() => first.RunAsync(request)); + + EntityHarness retry = CreateHarness(new RecordingAgent("agent"), state); + await retry.RunAsync(request); + DurableAgentState committed = Assert.IsType(retry.PersistedState); + Assert.Equal(DurableAgentStateCompletionReceipt.SucceededOutcome, committed.Data.CompletionReceipts!["new"].Outcome); + } + + [Fact] + public async Task CancellationAfterModelOutputDoesNotPublishCompletionAsync() + { + using CancellationTokenSource cancellation = new(); + DurableAgentState state = new(); + RecordingAgent agent = new("agent") { OnStreamCompleted = () => cancellation.Cancel() }; + EntityHarness harness = CreateHarness(agent, state, cancellationToken: cancellation.Token); + + await Assert.ThrowsAnyAsync( + () => harness.RunAsync(new RunRequest("request") { CorrelationId = "new" })); + + Assert.Equal(1, agent.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Null(state.Data.CompletionReceipts); + } + + [Fact] + public async Task ResponseHandlerCannotCommitWithoutConsumingCompleteModelStreamAsync() + { + DurableAgentState state = new(); + Mock handler = new(); + handler.Setup(value => value.OnStreamingResponseUpdateAsync( + It.IsAny>(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, responseHandler: handler.Object); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("request") { CorrelationId = "new" })); + + Assert.False(harness.StateWasPersisted); + Assert.Null(state.Data.CompletionReceipts); + } + + [Fact] + public async Task ResponseMetadataIsCapturedBeforeCallerCanMutateReturnedResponseAsync() + { + DateTimeOffset createdAt = new(2026, 9, 10, 5, 0, 0, TimeSpan.Zero); + AgentResponseUpdate update = new(ChatRole.Assistant, "response") + { + ResponseId = "response-id", + CreatedAt = createdAt, + FinishReason = ChatFinishReason.Stop, + AdditionalProperties = new() { ["region"] = "test", ["explicitNull"] = null }, + }; +#pragma warning disable MEAI001 + update.ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); +#pragma warning restore MEAI001 + update.Contents.Add(new UsageContent(new UsageDetails { InputTokenCount = 4, OutputTokenCount = 2, TotalTokenCount = 6 })); + EntityHarness harness = CreateHarness(new RecordingAgent("agent") { ResponseUpdate = update }, new DurableAgentState()); + + AgentResponse original = await harness.RunAsync(new RunRequest("request") { CorrelationId = "new" }); + original.Messages.Clear(); + original.AdditionalProperties!["region"] = "mutated"; + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + DurableAgentRunOutcome outcome = DurableAgentStateOutcomeResolver.Resolve(committed, "new", DateTimeOffset.UtcNow); + AgentResponse stored = outcome.Response!; + + Assert.Equal("response", stored.Text); + Assert.Equal("response-id", stored.ResponseId); + Assert.Equal(original.AgentId, stored.AgentId); + Assert.Equal(createdAt, stored.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, stored.FinishReason); + Assert.Equal(6, stored.Usage!.TotalTokenCount); +#pragma warning disable MEAI001 + Assert.Equal(new byte[] { 1, 2, 3 }, stored.ContinuationToken!.ToBytes().ToArray()); +#pragma warning restore MEAI001 + Assert.Equal("test", Assert.IsType(stored.AdditionalProperties!["region"]).GetString()); + Assert.Equal(JsonValueKind.Null, Assert.IsType(stored.AdditionalProperties["explicitNull"]).ValueKind); + Assert.Equal(JsonValueKind.Undefined, outcome.Value.ValueKind); + } + + [Fact] + public async Task InvalidTerminalMetadataFailsBeforePublishingStateAsync() + { + DurableAgentState state = new(); + RecordingAgent agent = new("agent") + { + ResponseUpdate = new AgentResponseUpdate(ChatRole.Assistant, "response") + { + ResponseId = new string('x', DurableAgentStateContract.MaxIdentifierLength + 1), + }, + }; + EntityHarness harness = CreateHarness(agent, state); + + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("request") { CorrelationId = "new" })); + + Assert.False(harness.StateWasPersisted); + Assert.Empty(state.Data.ConversationHistory); + Assert.Null(state.Data.CompletionReceipts); + } + + [Theory] + [InlineData("null")] + [InlineData("false")] + [InlineData("0")] + [InlineData("\"\"")] + [InlineData("[]")] + [InlineData("{}")] + public async Task JsonResponseFormatDoesNotInventValueFromTextAsync(string text) + { + EntityHarness harness = CreateHarness(new RecordingAgent("agent") { ResponseText = text }, new DurableAgentState()); + await harness.RunAsync(new RunRequest("request", responseFormat: ChatResponseFormat.Json) { CorrelationId = "new" }); + DurableAgentState committed = Assert.IsType(harness.PersistedState); + DurableAgentState reloaded = Reload(committed); + + DurableAgentRunOutcome outcome = DurableAgentStateOutcomeResolver.Resolve(reloaded, "new", DateTimeOffset.UtcNow); + Assert.Equal(JsonValueKind.Undefined, outcome.Value.ValueKind); + Assert.Equal(text, outcome.Response!.Text); + } + + [Fact] + public async Task TextDoesNotBecomeAStructuredValueWithoutIndependentProducerEvidenceAsync() + { + DurableAgentState state = new(); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state); + + await harness.RunAsync(new RunRequest("request", responseFormat: ChatResponseFormat.Json) { CorrelationId = "new" }); + + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + Assert.Equal(JsonValueKind.Undefined, committed.Data.TerminalResults!["new"].Response!.Value.ValueKind); + } + + [Fact] + public async Task RetentionUsesInjectedClockAndPreservesCompletionWhenResultExpiresAsync() + { + DateTimeOffset start = new(2026, 9, 10, 5, 0, 0, TimeSpan.Zero); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), new DurableAgentState(), + resultRetentionPeriod: TimeSpan.FromMinutes(2), timeProvider: new FixedTimeProvider(start)); + await harness.RunAsync(new RunRequest("request") { CorrelationId = "new" }); + DurableAgentState committed = Assert.IsType(harness.PersistedState); + + Assert.Equal(start.AddMinutes(2), committed.Data.TerminalResults!["new"].ResultExpiresAt); + Assert.Null(committed.Data.ExpirationTimeUtc); + EntityHarness duplicate = CreateHarness(new RecordingAgent("agent"), Reload(committed), + timeProvider: new FixedTimeProvider(start.AddMinutes(2))); + DurableAgentResultUnavailableException exception = await Assert.ThrowsAsync( + () => duplicate.RunAsync(new RunRequest([]) { CorrelationId = "new" })); + Assert.Equal(DurableAgentStateCompletionReceipt.SucceededOutcome, exception.Outcome); + Assert.Single(committed.Data.CompletionReceipts!); + } + + [Fact] + public async Task PythonShapedCompactedLegacyStateStaysLegacyAndPreservesOpaqueMetadataAsync() + { + DurableAgentState state = ReadFixture("shared-durable-agent-state-1.2-python-shape.json"); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, + registerWithFactory: true, onFactoryInvoked: () => throw new InvalidOperationException("factory must not run"), + authorizeLegacyMigration: false); + + await harness.RunAsync(new RunRequest([]) { CorrelationId = "corr-python" }); + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + + 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()); + Assert.True(committed.UnknownProperties!["futureRootProperty"].GetProperty("preserve").GetBoolean()); + Assert.Equal("interop-fixture", committed.ExtensionData!["rootProducer"].GetString()); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, committed.SchemaVersion); + Assert.Null(committed.Data.CompletionReceipts); + Assert.Equal(JsonValueKind.Object, + committed.Data.ConversationHistory.OfType() + .Single(response => response.CorrelationId == "corr-python").Usage!.ExtensionData!["futureObject"].ValueKind); + Assert.Equal(JsonValueKind.Undefined, committed.Data.HistoryBinding.ValueKind); + Assert.Null(state.Data.TerminalResults); + } + + [Fact] + public async Task RevisedCommitKeepsSessionIngestionTruncationAndReceiptsIndependentAsync() + { + DurableAgentState state = ReadFixture("shared-durable-agent-state-2.0-pruned.json"); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state); + + await harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" }); + + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + 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.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; + Assert.Equal(3, state.Data.IngestedPositions!["example-producer"]); + } + + [Fact] + public async Task PythonShapedFailedUnavailableReceiptBypassesFactoryWithRolloutOffAsync() + { + DurableAgentState state = ReadFixture("shared-durable-agent-state-2.0.json"); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, enableMailboxWrites: false, + registerWithFactory: true, onFactoryInvoked: () => throw new InvalidOperationException("factory must not run")); + + DurableAgentResultUnavailableException exception = await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest([]) { CorrelationId = "corr-expired" })); + + Assert.Equal(DurableAgentStateCompletionReceipt.FailedOutcome, exception.Outcome); + Assert.False(harness.StateWasPersisted); + } + + private static DurableAgentState ReadFixture(string name) => + JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", name)), + DurableAgentStateJsonContext.Default.DurableAgentState)!; + + private static DurableAgentState Reload(DurableAgentState state) => + JsonSerializer.Deserialize( + JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState), + DurableAgentStateJsonContext.Default.DurableAgentState)!; + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + [Fact] + public async Task ReusedSuccessfulCorrelationReturnsWithoutComparingContentOrConstructingAgentAsync() + { + DurableAgentState initialState = CreateStateWithResponse( + "duplicate", + "response"); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + initialState, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + AgentResponse response = await harness.RunAsync( + new RunRequest("different logical request") { CorrelationId = "duplicate" }); + + Assert.Equal("response", response.Text); + Assert.Equal(0, factoryInvocationCount); + DurableAgentState persisted = Assert.IsType(harness.PersistedState); + Assert.Equal(DurableAgentState.RevisedSchemaVersion, persisted.SchemaVersion); + Assert.Equal("response", persisted.Data.TerminalResults?["duplicate"].Response?.ToResponse().Text); + } + + [Fact] + public async Task ReusedErrorCorrelationWithEmptyMessagesThrowsBeforeValidationAsync() + { + DurableAgentState initialState = CreateStateWithResponse( + "duplicate", + "persisted failure", + isError: true); + RecordingAgent agent = new("agent"); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness(agent, initialState, + registerWithFactory: true, onFactoryInvoked: () => factoryInvocationCount++); + + DurableAgentTerminalException exception = await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest([]) { CorrelationId = "duplicate" })); + + Assert.Equal("persisted failure", exception.Response?.Text); + Assert.Equal("legacyErrorResponse", exception.Code); + Assert.Equal("duplicate", exception.CorrelationId); + Assert.Equal(0, agent.InvocationCount); + Assert.Equal(0, factoryInvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, initialState.SchemaVersion); + Assert.Null(initialState.Data.CompletionReceipts); + } + + [Fact] + public async Task DuplicateTerminalStateThrowsBeforeValidationAndAgentConstructionAsync() + { + DurableAgentState initialState = CreateStateWithResponse( + "duplicate", + "first"); + initialState.Data.ConversationHistory.Add( + CreateResponse("duplicate", "second", isError: true)); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + initialState, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + DurableAgentStateCorruptionException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest([]) { CorrelationId = "duplicate" })); + + Assert.Equal(2, exception.TerminalResponseCount); + Assert.Equal(0, factoryInvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task InvalidCorrelationAndNewEmptyRequestFailBeforeAgentSideEffectsAsync() + { + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + new DurableAgentState(), + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + ArgumentException correlationException = await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest([]) { CorrelationId = "" })); + ArgumentException messageException = await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest([]) { CorrelationId = "new" })); + + Assert.Equal("request", correlationException.ParamName); + Assert.Equal("request", messageException.ParamName); + Assert.Equal(0, factoryInvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ModelFailureDoesNotMutateOrPersistHydratedStateAsync() + { + RecordingAgent agent = new("agent") + { + Exception = new InvalidOperationException("model failed"), + }; + DurableAgentState initialState = CreateStateWithResponse( + "old", + "old response"); + EntityHarness harness = CreateHarness(agent, initialState); + + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest("new request") { CorrelationId = "new" })); + + Assert.False(harness.StateWasPersisted); + Assert.Single(initialState.Data.ConversationHistory); + Assert.DoesNotContain( + initialState.Data.ConversationHistory, + entry => entry.CorrelationId == "new"); + } + + [Fact] + public async Task CancellationDoesNotCreateCompletionOrMutateHydratedStateAsync() + { + RecordingAgent agent = new("agent") + { + Exception = new OperationCanceledException("cancelled"), + }; + DurableAgentState initialState = new(); + EntityHarness harness = CreateHarness(agent, initialState); + + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest("new request") { CorrelationId = "new" })); + + Assert.False(harness.StateWasPersisted); + Assert.Empty(initialState.Data.ConversationHistory); + Assert.Null(initialState.Data.TerminalResults); + Assert.Null(initialState.Data.CompletionReceipts); + } + + [Fact] + public async Task ResultSerializationFailureDoesNotCommitTranscriptOrCompletionAsync() + { + RecordingAgent agent = new("agent") + { + UnsupportedResponseMetadata = new object(), + }; + DurableAgentState initialState = new(); + EntityHarness harness = CreateHarness(agent, initialState); + + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest("new request") { CorrelationId = "new" })); + + Assert.Equal(1, agent.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Empty(initialState.Data.ConversationHistory); + Assert.Null(initialState.Data.TerminalResults); + Assert.Null(initialState.Data.CompletionReceipts); + } + + [Fact] + public async Task NewRequestUsesExistingHistoryAndCommitsRequestAndResponseAsync() + { + RecordingAgent agent = new("agent"); + DurableAgentState initialState = CreateStateWithResponse( + "old", + "old response"); + EntityHarness harness = CreateHarness(agent, initialState); + + AgentResponse response = await harness.RunAsync( + new RunRequest("new request") { CorrelationId = "new" }); + + Assert.Equal("response", response.Text); + Assert.Equal(["old response", "new request"], agent.LastMessages.Select(message => message.Text)); + DurableAgentState persisted = Assert.IsType(harness.PersistedState); + Assert.Equal(3, persisted.Data.ConversationHistory.Count); + Assert.Equal(DurableAgentState.RevisedSchemaVersion, persisted.SchemaVersion); + Assert.Equal(2, persisted.Data.TerminalResults?.Count); + Assert.Equal(2, persisted.Data.CompletionReceipts?.Count); + Assert.Null(persisted.Data.TerminalResults?["new"].ResultExpiresAt); + Assert.Null(persisted.Data.CompletionReceipts?["new"].ResultExpiresAt); + Assert.Single(initialState.Data.ConversationHistory); + } + + [Fact] + public async Task RevisedDuplicateAfterTranscriptRemovalBypassesAgentAsync() + { + DurableAgentState initialState = CreateRevisedState("duplicate", "mailbox"); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + initialState, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + AgentResponse response = await harness.RunAsync( + new RunRequest("different request") { CorrelationId = "duplicate" }); + + Assert.Equal("mailbox", response.Text); + Assert.Equal(0, factoryInvocationCount); + Assert.Empty(initialState.Data.ConversationHistory); + } + + [Fact] + public async Task RevisedUnavailableCompletionNeverExecutesAgentOrFallsBackToTranscriptAsync() + { + DurableAgentState initialState = CreateRevisedState( + "duplicate", + "removed", + resultAvailable: false); + initialState.Data.ConversationHistory.Add( + CreateResponse("duplicate", "stale transcript")); + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + initialState, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + DurableAgentResultUnavailableException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest("different request") { CorrelationId = "duplicate" })); + + Assert.Equal("duplicate", exception.CorrelationId); + Assert.Equal(0, factoryInvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public async Task ColdReloadUsesCommittedMailboxWithoutAgentExecutionAsync() + { + RecordingAgent firstAgent = new("agent"); + EntityHarness firstHarness = CreateHarness(firstAgent, new DurableAgentState()); + RunRequest request = new("new request") { CorrelationId = "correlation" }; + + _ = await firstHarness.RunAsync(request); + DurableAgentState persisted = Assert.IsType( + firstHarness.PersistedState); + persisted.Data.ConversationHistory.Clear(); + string json = JsonSerializer.Serialize( + persisted, + DurableAgentStateJsonContext.Default.DurableAgentState); + DurableAgentState reloaded = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + int factoryInvocationCount = 0; + EntityHarness secondHarness = CreateHarness( + new RecordingAgent("agent"), + reloaded, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + AgentResponse duplicate = await secondHarness.RunAsync(request); + + Assert.Equal("response", duplicate.Text); + Assert.Equal(0, factoryInvocationCount); + } + + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("false")] + [InlineData("0")] + [InlineData("\"\"")] + [InlineData("\"profile\"")] + [InlineData("[]")] + [InlineData("[null,false,0,{}]")] + [InlineData("{}")] + [InlineData("""{"version":99,"ownerKind":"future","providerKey":null,"$type":"untrusted","nested":{"preserve":true}}""")] + public async Task OpaqueHistoryBindingIsPreservedWithoutSelectingProviderAsync(string? bindingJson) + { + using JsonDocument? bindingDocument = bindingJson is null ? null : JsonDocument.Parse(bindingJson); + 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 = bindingDocument?.RootElement ?? default, + }, + }; + bindingDocument?.Dispose(); + DurableAgentState clone = state.Clone(); + Assert.Equal(state.Data.HistoryBinding.ValueKind, clone.Data.HistoryBinding.ValueKind); + if (bindingJson is not null) + { + Assert.True(JsonElement.DeepEquals(state.Data.HistoryBinding, clone.Data.HistoryBinding)); + } + + int factoryInvocationCount = 0; + EntityHarness harness = CreateHarness( + new RecordingAgent("agent"), + state, + registerWithFactory: true, + onFactoryInvoked: () => factoryInvocationCount++); + + await harness.RunAsync(new RunRequest("new request") { CorrelationId = "new" }); + + Assert.Equal(1, factoryInvocationCount); + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + Assert.NotSame(state.Data, committed.Data); + 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)); + } + + Assert.Single(state.Data.CompletionReceipts!); + 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()); + } + + private static DurableAgentState CreateStateWithResponse( + string correlationId, + string text, + bool isError = false) + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add(CreateResponse(correlationId, text, isError)); + return state; + } + + private static DurableAgentStateResponse CreateResponse( + string correlationId, + string text, + bool isError = false) + { + IReadOnlyList messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, text)), + ]; + return isError + ? new DurableAgentStateErrorResponse + { + CorrelationId = correlationId, + CreatedAt = DateTimeOffset.UtcNow, + Messages = messages, + } + : new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = DateTimeOffset.UtcNow, + Messages = messages, + }; + } + + private static DurableAgentState CreateRevisedState( + string correlationId, + string text, + bool resultAvailable = true) + { + DateTimeOffset completedAt = DateTimeOffset.UtcNow.AddMinutes(-1); + DurableAgentStateTerminalResult result = + DurableAgentStateTerminalResult.FromResponse( + correlationId, + new AgentResponse(new ChatMessage(ChatRole.Assistant, text)), + completedAt); + return new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = [], + TerminalResults = resultAvailable + ? new Dictionary + { + [correlationId] = result, + } + : new Dictionary(), + CompletionReceipts = new Dictionary + { + [correlationId] = new() + { + CorrelationId = correlationId, + Outcome = result.Outcome, + CompletedAt = completedAt, + ResultState = resultAvailable + ? DurableAgentStateCompletionReceipt.AvailableResult + : DurableAgentStateCompletionReceipt.UnavailableResult, + ResultUnavailableAt = resultAvailable ? null : completedAt.AddSeconds(1), + }, + }, + }, + }; + } + + internal static EntityHarness CreateHarness( + RecordingAgent agent, + DurableAgentState? state, + bool registerWithFactory = false, + Action? onFactoryInvoked = null, + bool enableMailboxWrites = true, + TimeSpan? resultRetentionPeriod = null, + TimeProvider? timeProvider = null, + Action? onCommit = null, + IAgentResponseHandler? responseHandler = null, + bool authorizeLegacyMigration = true, + CancellationToken cancellationToken = default) + { + AgentSessionId sessionId = new(agent.Name!, "session"); + DurableAgentsOptions options = new() + { + DefaultTimeToLive = null, + EnableMailboxWrites = enableMailboxWrites, + ResultRetentionPeriod = resultRetentionPeriod, + AuthorizeLegacyMigration = authorizeLegacyMigration + ? candidate => ReferenceEquals(candidate, state) + : null, + }; + if (registerWithFactory) + { + options.AddAIAgentFactory( + agent.Name!, + _ => + { + onFactoryInvoked?.Invoke(); + return agent; + }); + } + else + { + options.AddAIAgent(agent); + } + + Dictionary services = new() + { + [typeof(DurableTaskClient)] = new Mock("test").Object, + [typeof(ILoggerFactory)] = Extensions.Logging.Abstractions.NullLoggerFactory.Instance, + [typeof(DurableAgentsOptions)] = options, + [typeof(IReadOnlyDictionary>)] = + options.GetAgentFactories(), + [typeof(IHostApplicationLifetime)] = Mock.Of( + lifetime => lifetime.ApplicationStopping == CancellationToken.None), + [typeof(TimeProvider)] = timeProvider ?? TimeProvider.System, + }; + if (responseHandler is not null) + { + services[typeof(IAgentResponseHandler)] = responseHandler; + } + + 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 => + { + onCommit?.Invoke(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(new DictionaryServiceProvider(services), cancellationToken); + return new EntityHarness(entity, operation, () => persistedState); + } + + internal sealed class EntityHarness( + AgentEntity entity, + Mock operation, + Func persistedState) + { + public object? PersistedState => persistedState(); + + public bool StateWasPersisted => this.PersistedState is not null; + + 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); + } + } + + internal sealed class RecordingAgent(string name) : AIAgent + { + public override string? Name => name; + + public Exception? Exception { get; init; } + + public object? UnsupportedResponseMetadata { get; init; } + + public string ResponseText { get; init; } = "response"; + + public AgentResponseUpdate? ResponseUpdate { get; init; } + + public Action? OnStreamCompleted { get; init; } + + public int InvocationCount { get; private set; } + + 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 { })); + + 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.InvocationCount++; + this.LastMessages = messages.ToList(); + if (this.Exception is not null) + { + throw this.Exception; + } + + await Task.Yield(); + yield return this.ResponseUpdate ?? new AgentResponseUpdate(ChatRole.Assistant, this.ResponseText) + { + AdditionalProperties = this.UnsupportedResponseMetadata is null + ? null + : new AdditionalPropertiesDictionary + { + ["unsupported"] = this.UnsupportedResponseMetadata, + }, + }; + this.OnStreamCompleted?.Invoke(); + } + + private sealed class RecordingSession : AgentSession; + } + + private sealed class DictionaryServiceProvider(IReadOnlyDictionary services) : IServiceProvider + { + public object? GetService(Type serviceType) => + services.TryGetValue(serviceType, out object? service) ? service : null; + } +} 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..26bd892 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityHistoryTests.cs @@ -0,0 +1,2065 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; +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 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); + } + + [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); + } + + [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 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 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 AutoRetentionRunsOnCompletedEntityExecutionAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState initialState = CreateLargeState(); + ConcurrentQueue measuredInstruments = new(); + using MeterListener listener = new(); + listener.InstrumentPublished = static (instrument, meterListener) => + { + if (instrument.Meter.Name == DurableAgentTelemetry.MeterName) + { + meterListener.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback( + (instrument, _, tags, _) => + { + foreach (KeyValuePair tag in tags) + { + if (tag.Key == DurableAgentTelemetry.AgentNameTagName && + string.Equals(tag.Value as string, "agent", StringComparison.Ordinal)) + { + measuredInstruments.Enqueue(instrument.Name); + break; + } + } + }); + listener.Start(); + + DurableAgentState persisted = await RunEntityAsync( + agent, + initialState, + new RunRequest(new string('n', 500)) { CorrelationId = "new" }, + options => + { + options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + options.MaxStateBytes = 5_000; + }); + + Assert.NotNull(persisted.Data.Truncation); + Assert.DoesNotContain(persisted.Data.ConversationHistory, entry => entry.CorrelationId == "oldest"); + Assert.Contains(persisted.Data.ConversationHistory, entry => entry.CorrelationId == "new"); + Assert.Contains("oldest", persisted.Data.TerminalResults!.Keys); + Assert.Contains("oldest", persisted.Data.CompletionReceipts!.Keys); + Assert.Contains( + DurableAgentTelemetry.RetentionOperationsInstrumentName, + measuredInstruments); + + DurableAgentState reloaded = DeserializeState(SerializeState(persisted)); + DurableAgentRunOutcome retainedOutcome = + DurableAgentStateOutcomeResolver.Resolve( + reloaded, + "oldest", + DateTimeOffset.UtcNow); + Assert.Equal(DurableAgentRunOutcomeKind.Succeeded, retainedOutcome.Kind); + Assert.Equal(new string('b', 600), retainedOutcome.Response?.Text); + + RecordingChatClient duplicateClient = new(); + AgentResponse duplicate = await CreateHarness( + new ChatClientAgent(duplicateClient, name: "agent"), + reloaded).RunAsync( + new RunRequest("different request") { CorrelationId = "oldest" }); + Assert.Equal(new string('b', 600), duplicate.Text); + Assert.Equal(0, duplicateClient.InvocationCount); + + RecordingChatClient nextClient = new(); + _ = await RunEntityAsync( + new ChatClientAgent(nextClient, name: "agent"), + DeserializeState(SerializeState(persisted)), + new RunRequest("next request") { CorrelationId = "next" }); + Assert.DoesNotContain( + nextClient.LastMessages, + message => message.Text == new string('a', 600) || + message.Text == new string('b', 600)); + } + + [Fact] + public async Task DefaultKeepAllDoesNotEvictTranscriptUnderConfiguredPressureAsync() + { + RecordingChatClient client = new(); + DurableAgentState initialState = CreateLargeState(); + + DurableAgentState persisted = await RunEntityAsync( + new ChatClientAgent(client, name: "agent"), + initialState, + new RunRequest("new request") { CorrelationId = "new" }, + options => options.MaxStateBytes = 500); + + Assert.Null(persisted.Data.Truncation); + Assert.Contains( + persisted.Data.ConversationHistory, + entry => entry.CorrelationId == "oldest"); + } + + [Fact] + public async Task LegacyAutoWithoutAuthorizedMigrationFailsBeforeModelExecutionAsync() + { + RecordingChatClient client = new(); + DurableAgentState initialState = + CreateStateWithExchange("old", "old request", "old response"); + EntityHarness harness = CreateHarness( + new ChatClientAgent(client, name: "agent"), + initialState, + options => + { + options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + options.MaxStateBytes = 500; + options.AuthorizeLegacyMigration = null; + }); + + DurableAgentStateCorruptionException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest("new request") { CorrelationId = "new" })); + + Assert.Contains( + "independently authoritative complete history", + exception.Message, + StringComparison.Ordinal); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, initialState.SchemaVersion); + } + + [Fact] + public async Task DuplicateLegacyAutoMigrationStillEnforcesStateBudgetAsync() + { + RecordingChatClient client = new(); + DurableAgentState initialState = CreateStateWithExchange( + "duplicate", + new string('q', 1_000), + new string('a', 2_000)); + EntityHarness harness = CreateHarness( + new ChatClientAgent(client, name: "agent"), + initialState, + options => + { + options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + options.MaxStateBytes = 500; + }); + + _ = await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest("different request") { CorrelationId = "duplicate" })); + + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, initialState.SchemaVersion); + } + + [Fact] + public async Task DuplicateLegacyAutoWithoutAuthorizedMigrationFailsClosedAsync() + { + RecordingChatClient client = new(); + DurableAgentState initialState = + CreateStateWithExchange("duplicate", "request", "response"); + EntityHarness harness = CreateHarness( + new ChatClientAgent(client, name: "agent"), + initialState, + options => + { + options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + options.MaxStateBytes = 10_000; + options.AuthorizeLegacyMigration = null; + }); + + DurableAgentStateCorruptionException exception = + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest("different request") { CorrelationId = "duplicate" })); + + Assert.Contains( + "independently authoritative complete history", + exception.Message, + StringComparison.Ordinal); + Assert.Equal(0, client.InvocationCount); + Assert.False(harness.StateWasPersisted); + } + + [Fact] + public void AutoInitializesNewSessionWithMailboxStateWithoutInternalGate() + { + DurableAgentsOptions options = new() + { + EnableMailboxWrites = false, + HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto, + }; + Dictionary services = new() + { + [typeof(DurableTaskClient)] = new Mock("test").Object, + [typeof(ILoggerFactory)] = new ListLoggerFactory(new ListLoggerProvider()), + [typeof(DurableAgentsOptions)] = options, + }; + TestableAgentEntity entity = new(new DictionaryServiceProvider(services)); + Mock operation = new(); + operation.SetupGet(value => value.Name).Returns(nameof(AgentEntity.Run)); + + DurableAgentState initialized = entity.Initialize(operation.Object); + + Assert.Equal(DurableAgentState.RevisedSchemaVersion, initialized.SchemaVersion); + Assert.True(initialized.MailboxWritesAuthorized); + Assert.Empty(initialized.Data.TerminalResults!); + Assert.Empty(initialized.Data.CompletionReceipts!); + } + + [Fact] + public async Task OversizedProtectedStateFailsWithoutPersistenceAsync() + { + RecordingChatClient client = new(); + ChatClientAgent agent = new(client, name: "agent"); + DurableAgentState initialState = new(); + string originalState = SerializeState(initialState); + EntityHarness harness = CreateHarness( + agent, + initialState, + options => + { + options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + options.MaxStateBytes = 500; + }); + + await Assert.ThrowsAsync( + () => harness.RunAsync( + new RunRequest(new string('x', 2_000)) { CorrelationId = "new" })); + + Assert.False(harness.StateWasPersisted); + Assert.Equal(originalState, SerializeState(initialState)); + } + + [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) + { + AgentSessionId sessionId = new(agent.Name!, "session"); + DurableAgentsOptions options = new() + { + DefaultTimeToLive = null, + EnableMailboxWrites = true, + AuthorizeLegacyMigration = static _ => true, + }; + 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 DurableAgentState CreateLargeState() + { + DurableAgentState state = new(); + DateTimeOffset now = DateTimeOffset.UtcNow; + AddExchange(state, "oldest", new string('a', 600), new string('b', 600), now.AddMinutes(-10)); + AddExchange(state, "middle", new string('c', 600), new string('d', 600), now.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 TestableAgentEntity(IServiceProvider services) : AgentEntity(services) + { + public DurableAgentState Initialize(TaskEntityOperation operation) => + this.InitializeState(operation); + } + + 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 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/AgentEntityTimeToLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityTimeToLiveTests.cs new file mode 100644 index 0000000..4bdd6a4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityTimeToLiveTests.cs @@ -0,0 +1,417 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +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 AgentEntityTimeToLiveTests +{ + private static readonly DateTimeOffset s_startTime = + new(2026, 9, 7, 8, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task FirstInteractionSchedulesExpirationCheckAsync() + { + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(10)); + + await harness.RunAsync("first"); + + Assert.Equal(s_startTime.AddMinutes(10).UtcDateTime, harness.State!.Data.ExpirationTimeUtc); + ScheduledSignal signal = Assert.Single(harness.Signals); + Assert.Equal(s_startTime.AddMinutes(10), signal.SignalTime); + Assert.Equal(s_startTime.AddMinutes(10).UtcDateTime, signal.Input.ExpectedExpirationTimeUtc); + Assert.Single(harness.State.Data.CompletionReceipts!); + Assert.Null(Assert.Single(harness.State.Data.TerminalResults!).Value.ResultExpiresAt); + } + + [Fact] + public async Task SchedulingFailureDoesNotMutateHydratedStateOrCommitReceiptAsync() + { + DurableAgentState state = new(); + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(10), state, + onSignal: () => throw new InvalidOperationException("signal scheduling failed")); + + await Assert.ThrowsAsync(() => harness.RunAsync("request")); + + Assert.Same(state, harness.State); + Assert.Empty(state.Data.ConversationHistory); + Assert.Null(state.Data.ExpirationTimeUtc); + Assert.Null(state.Data.CompletionReceipts); + } + + [Fact] + public async Task RevisedReceiptSurvivesLegacyDeadlineWithoutAgreedDeletionPolicyAsync() + { + DurableAgentState state = DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState( + new DurableAgentState(), hasAuthoritativeLegacyHistory: true); + DurableAgentStateOutcomeResolver.AddSuccessfulResult(state, "old", new AgentResponse(), s_startTime.AddMinutes(-5)); + state.Data.ExpirationTimeUtc = s_startTime.AddMinutes(-1).UtcDateTime; + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(10), state); + harness.Options.EnableMailboxEntityDeletion = false; + + await harness.CheckExpirationAsync(null); + + Assert.Same(state, harness.State); + Assert.Single(state.Data.CompletionReceipts!); + Assert.Equal(s_startTime.AddMinutes(-1).UtcDateTime, state.Data.ExpirationTimeUtc); + Assert.Empty(harness.Signals); + } + + [Fact] + public async Task LaterInteractionExtendsExpirationAndEarlierCheckMovesChainAsync() + { + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(10)); + await harness.RunAsync("first"); + ScheduledSignal firstSignal = Assert.Single(harness.Signals); + + harness.Clock.Advance(TimeSpan.FromMinutes(2)); + await harness.RunAsync("second"); + + Assert.Equal(s_startTime.AddMinutes(12).UtcDateTime, harness.State!.Data.ExpirationTimeUtc); + Assert.Single(harness.Signals); + + harness.Clock.SetUtcNow(firstSignal.SignalTime); + await harness.CheckExpirationAsync(firstSignal.Input); + + Assert.NotNull(harness.State); + Assert.Equal(2, harness.Signals.Count); + Assert.Equal(s_startTime.AddMinutes(12), harness.Signals[^1].SignalTime); + } + + [Fact] + public async Task ShorterTimeToLiveSchedulesEarlierCheckAndStaleSignalIsHarmlessAsync() + { + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(10)); + await harness.RunAsync("first"); + ScheduledSignal originalSignal = Assert.Single(harness.Signals); + + harness.Clock.Advance(TimeSpan.FromMinutes(1)); + harness.Options.DefaultTimeToLive = TimeSpan.FromMinutes(1); + await harness.RunAsync("second"); + + Assert.Equal(2, harness.Signals.Count); + ScheduledSignal shorterSignal = harness.Signals[^1]; + Assert.Equal(s_startTime.AddMinutes(2), shorterSignal.SignalTime); + Assert.Equal(2, harness.State!.Data.CompletionReceipts!.Count); + + harness.Clock.SetUtcNow(shorterSignal.SignalTime); + await harness.CheckExpirationAsync(shorterSignal.Input); + Assert.Null(harness.State); + + harness.Clock.SetUtcNow(originalSignal.SignalTime); + await harness.CheckExpirationAsync(originalSignal.Input); + Assert.Null(harness.State); + } + + [Fact] + public async Task DisablingTimeToLiveClearsExpirationAndMakesOldSignalHarmlessAsync() + { + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(2)); + await harness.RunAsync("first"); + ScheduledSignal signal = Assert.Single(harness.Signals); + + harness.Clock.Advance(TimeSpan.FromMinutes(1)); + harness.Options.DefaultTimeToLive = null; + await harness.RunAsync("second"); + + Assert.Null(harness.State!.Data.ExpirationTimeUtc); + + harness.Clock.SetUtcNow(signal.SignalTime); + await harness.CheckExpirationAsync(signal.Input); + + Assert.NotNull(harness.State); + Assert.Null(harness.State.Data.ExpirationTimeUtc); + Assert.Single(harness.Signals); + } + + [Fact] + public async Task MissingAgentConfigurationClearsExpirationWithoutDeletingStateAsync() + { + DurableAgentState state = new(); + state.Data.ExpirationTimeUtc = s_startTime.AddMinutes(5).UtcDateTime; + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CorrelationId = "meaningful", + CreatedAt = s_startTime, + }); + EntityHarness harness = CreateHarness( + TimeSpan.FromMinutes(5), + state, + registerAgent: false); + + await harness.CheckExpirationAsync( + new AgentEntityDeletionCheck(state.Data.ExpirationTimeUtc.Value)); + + Assert.NotNull(harness.State); + Assert.Null(harness.State.Data.ExpirationTimeUtc); + Assert.Single(harness.State.Data.ConversationHistory); + Assert.Empty(harness.Signals); + } + + [Fact] + public async Task AutoRejectsUnauthorizedLegacyTtlMutationAsync() + { + DurableAgentState state = new(); + state.Data.ExpirationTimeUtc = s_startTime.AddMinutes(5).UtcDateTime; + EntityHarness harness = CreateHarness( + TimeSpan.FromMinutes(5), + state, + registerAgent: false); + harness.Options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + harness.Options.AuthorizeLegacyMigration = null; + + _ = await Assert.ThrowsAsync( + () => harness.CheckExpirationAsync( + new AgentEntityDeletionCheck(state.Data.ExpirationTimeUtc.Value))); + + Assert.Same(state, harness.State); + Assert.Equal(s_startTime.AddMinutes(5).UtcDateTime, state.Data.ExpirationTimeUtc); + } + + [Fact] + public async Task AutoAppliesProtectedFloorWhenClearingTtlAsync() + { + DurableAgentState state = DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState( + new DurableAgentState(), + hasAuthoritativeLegacyHistory: true); + DurableAgentStateOutcomeResolver.AddSuccessfulResult( + state, + "protected", + new AgentResponse(new ChatMessage(ChatRole.Assistant, new string('x', 2_000))), + s_startTime.AddMinutes(-5)); + state.Data.ExpirationTimeUtc = s_startTime.AddMinutes(5).UtcDateTime; + EntityHarness harness = CreateHarness( + TimeSpan.FromMinutes(5), + state, + registerAgent: false); + harness.Options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.Auto; + harness.Options.MaxStateBytes = 500; + + _ = await Assert.ThrowsAsync( + () => harness.CheckExpirationAsync( + new AgentEntityDeletionCheck(state.Data.ExpirationTimeUtc.Value))); + + Assert.Same(state, harness.State); + Assert.Equal(s_startTime.AddMinutes(5).UtcDateTime, state.Data.ExpirationTimeUtc); + Assert.Single(state.Data.TerminalResults!); + } + + [Fact] + public async Task StaleLaterCheckDoesNotRescheduleEarlierCurrentExpirationAsync() + { + DurableAgentState state = new(); + state.Data.ExpirationTimeUtc = s_startTime.AddMinutes(10).UtcDateTime; + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(10), state); + + await harness.CheckExpirationAsync( + new AgentEntityDeletionCheck(s_startTime.AddMinutes(20).UtcDateTime)); + + Assert.NotNull(harness.State); + Assert.Equal(s_startTime.AddMinutes(10).UtcDateTime, harness.State.Data.ExpirationTimeUtc); + Assert.Empty(harness.Signals); + } + + [Fact] + public async Task DelayedSignalAgainstDeletedEntityRemovesEmptyPlaceholderAsync() + { + EntityHarness harness = CreateHarness(TimeSpan.FromMinutes(10), state: null); + harness.DeleteState(); + + await harness.CheckExpirationAsync( + new AgentEntityDeletionCheck(s_startTime.UtcDateTime)); + + Assert.Null(harness.State); + Assert.Empty(harness.Signals); + } + + private static EntityHarness CreateHarness( + TimeSpan? timeToLive, + DurableAgentState? state = null, + bool registerAgent = true, + Action? onSignal = null) + { + const string AgentName = "agent"; + AgentSessionId sessionId = new(AgentName, "session"); + DurableAgentsOptions options = new() + { + DefaultTimeToLive = timeToLive, + MinimumTimeToLiveSignalDelay = TimeSpan.Zero, + EnableMailboxWrites = true, + EnableMailboxEntityDeletion = true, + AuthorizeLegacyMigration = _ => true, + }; + AIAgent agent = new StubAgent(AgentName); + if (registerAgent) + { + options.AddAIAgent(agent); + } + + ManualTimeProvider clock = new(s_startTime); + Dictionary services = new() + { + [typeof(DurableTaskClient)] = new Mock("test").Object, + [typeof(ILoggerFactory)] = Extensions.Logging.Abstractions.NullLoggerFactory.Instance, + [typeof(DurableAgentsOptions)] = options, + [typeof(IReadOnlyDictionary>)] = + options.GetAgentFactories(), + [typeof(IHostApplicationLifetime)] = Mock.Of( + lifetime => lifetime.ApplicationStopping == CancellationToken.None), + [typeof(TimeProvider)] = clock, + }; + + List signals = []; + Mock context = new(); + context.SetupGet(value => value.Id).Returns(sessionId); + context.Setup(value => value.SignalEntity( + sessionId, + nameof(AgentEntity.CheckAndDeleteIfExpired), + It.IsAny(), + It.IsAny())) + .Callback( + (_, _, input, signalOptions) => + { + onSignal?.Invoke(); + signals.Add(new( + Assert.IsType(input), + Assert.IsType(signalOptions?.SignalTime))); + }); + + DurableAgentState? currentState = state ?? new DurableAgentState(); + Mock entityState = new(); + entityState.SetupGet(value => value.HasState).Returns(() => currentState is not null); + entityState.Setup(value => value.GetState(typeof(DurableAgentState))) + .Returns(() => currentState); + entityState.Setup(value => value.SetState(It.IsAny())) + .Callback(value => currentState = value as DurableAgentState); + + return new EntityHarness( + new AgentEntity(new DictionaryServiceProvider(services), CancellationToken.None), + context, + entityState, + options, + clock, + signals, + () => currentState, + () => currentState = null); + } + + private sealed class EntityHarness( + AgentEntity entity, + Mock context, + Mock entityState, + DurableAgentsOptions options, + ManualTimeProvider clock, + List signals, + Func currentState, + Action deleteState) + { + public DurableAgentsOptions Options => options; + + public ManualTimeProvider Clock => clock; + + public List Signals => signals; + + public DurableAgentState? State => currentState(); + + public void DeleteState() => deleteState(); + + public async Task RunAsync(string message) + { + RunRequest request = new(message); + Mock operation = this.CreateOperation(nameof(AgentEntity.Run), hasInput: true); + operation.Setup(value => value.GetInput(typeof(RunRequest))).Returns(request); + _ = await ((ITaskEntity)entity).RunAsync(operation.Object); + } + + public async Task CheckExpirationAsync(AgentEntityDeletionCheck? check) + { + Mock operation = this.CreateOperation( + nameof(AgentEntity.CheckAndDeleteIfExpired), + hasInput: check is not null); + if (check is not null) + { + operation.Setup(value => value.GetInput(typeof(AgentEntityDeletionCheck))).Returns(check); + } + + _ = await ((ITaskEntity)entity).RunAsync(operation.Object); + } + + private Mock CreateOperation(string name, bool hasInput) + { + Mock operation = new(); + operation.SetupGet(value => value.Name).Returns(name); + operation.SetupGet(value => value.Context).Returns(context.Object); + operation.SetupGet(value => value.State).Returns(entityState.Object); + operation.SetupGet(value => value.HasInput).Returns(hasInput); + return operation; + } + } + + private sealed class StubAgent(string name) : AIAgent + { + public override string? Name => name; + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => new(new StubSession()); + + 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()); + + 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 return new AgentResponseUpdate(ChatRole.Assistant, "response"); + } + + private sealed class StubSession : AgentSession; + } + + private sealed class ManualTimeProvider(DateTimeOffset initialTime) : TimeProvider + { + private DateTimeOffset _utcNow = initialTime; + + public override DateTimeOffset GetUtcNow() => this._utcNow; + + public void Advance(TimeSpan amount) => this._utcNow += amount; + + public void SetUtcNow(DateTimeOffset value) => this._utcNow = value; + } + + private sealed class DictionaryServiceProvider(IReadOnlyDictionary services) : IServiceProvider + { + public object? GetService(Type serviceType) => + services.TryGetValue(serviceType, out object? service) ? service : null; + } + + private sealed record ScheduledSignal( + AgentEntityDeletionCheck Input, + DateTimeOffset SignalTime); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentRunHandleTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentRunHandleTests.cs new file mode 100644 index 0000000..a5b7591 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentRunHandleTests.cs @@ -0,0 +1,325 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class AgentRunHandleTests +{ + private static readonly AgentSessionId s_sessionId = new("agent", "session"); + + [Theory] + [InlineData("shared-durable-agent-state-2.0-lossless.json", "corr-lossless", nameof(DurableAgentRunOutcomeKind.Succeeded))] + [InlineData("shared-durable-agent-state-2.0-pruned.json", "corr-failed", nameof(DurableAgentRunOutcomeKind.Failed))] + [InlineData("shared-durable-agent-state-2.0.json", "corr-expired", nameof(DurableAgentRunOutcomeKind.CompletedResultUnavailable))] + public async Task PollingReadsSharedEntityFixturesAsync(string fileName, string correlationId, string expected) + { + DurableAgentState state = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName)), + DurableAgentStateJsonContext.Default.DurableAgentState)!; + + DurableAgentRunOutcome outcome = await CreateHandle(state, correlationId: correlationId).ReadAgentOutcomeAsync(); + + Assert.Equal(expected, outcome.Kind.ToString()); + if (outcome.Kind == DurableAgentRunOutcomeKind.CompletedResultUnavailable) + { + Assert.Equal(DurableAgentStateCompletionReceipt.FailedOutcome, outcome.Receipt!.Outcome); + } + + if (correlationId == "corr-lossless") + { + Assert.Equal(JsonValueKind.False, outcome.Value.ValueKind); + AIContent opaqueUri = outcome.Response!.Messages[0].Contents[1]; + JsonElement raw = Assert.IsType(opaqueUri.RawRepresentation); + Assert.Equal("uri", raw.GetProperty("$type").GetString()); + Assert.Equal("https://example.test/media/1", raw.GetProperty("uri").GetString()); + Assert.False(raw.TryGetProperty("mediaType", out _)); + JsonElement retained = Assert.IsType( + DurableAgentJsonUtilities.GetRetainedResult(outcome.Response!)); + JsonElement retainedUri = retained.GetProperty("messages")[0].GetProperty("contents")[1]; + Assert.Equal("uri", retainedUri.GetProperty("$type").GetString()); + Assert.False(retainedUri.TryGetProperty("mediaType", out _)); + } + } + + [Fact] + public async Task PollingReadsFullMetadataAndResponseIsIndependentOfMailboxAsync() + { + DurableAgentState state = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")), + DurableAgentStateJsonContext.Default.DurableAgentState)!; + AgentRunHandle handle = CreateHandle(state, correlationId: "corr-2", + timeProvider: new FixedTimeProvider(new(2026, 9, 10, 5, 0, 4, TimeSpan.Zero))); + + AgentResponse response = await handle.ReadAgentResponseAsync(); + Assert.Equal("response-id-2", response.ResponseId); + Assert.Equal("agent-id-2", response.AgentId); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); +#pragma warning disable MEAI001 + Assert.Equal(new byte[] { 1, 2, 3 }, response.ContinuationToken!.ToBytes().ToArray()); +#pragma warning restore MEAI001 + Assert.Equal(8, response.Usage!.TotalTokenCount); + Assert.Equal("test", Assert.IsType(response.AdditionalProperties!["region"]).GetString()); + response.Messages.Clear(); + response.AdditionalProperties["region"] = "mutated"; + + AgentResponse repeated = await handle.ReadAgentResponseAsync(); + Assert.Single(repeated.Messages); + Assert.Equal("test", Assert.IsType(repeated.AdditionalProperties!["region"]).GetString()); + } + + [Fact] + public async Task RevisedTranscriptWithoutReceiptRemainsPendingUntilCancelledAsync() + { + DurableAgentState state = CreateRevisedState("mailbox", includeTranscript: true); + state.Data.TerminalResults!.Clear(); + state.Data.CompletionReceipts!.Clear(); + using CancellationTokenSource cancellation = new(); + AgentRunHandle handle = CreateHandle(state, () => cancellation.Cancel()); + + await Assert.ThrowsAnyAsync( + () => handle.ReadAgentOutcomeAsync(cancellation.Token)); + } + + [Fact] + public async Task PollingReturnsUniqueSuccessfulResponseAsync() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add(CreateResponse("correlation", "success")); + + AgentResponse response = await CreateHandle(state).ReadAgentResponseAsync(); + + Assert.Equal("success", response.Text); + } + + [Fact] + public async Task PollingThrowsRecordedTerminalErrorAsync() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add(CreateErrorResponse("correlation", "failure")); + + DurableAgentTerminalException exception = + await Assert.ThrowsAsync( + () => CreateHandle(state).ReadAgentResponseAsync()); + + Assert.Equal("legacyErrorResponse", exception.Code); + Assert.Equal("failure", exception.Response?.Text); + } + + [Fact] + public async Task PollingUsesRevisedMailboxAfterTranscriptRemovalAsync() + { + DurableAgentState state = CreateRevisedState( + resultText: "mailbox", + includeTranscript: false); + + AgentResponse response = await CreateHandle(state).ReadAgentResponseAsync(); + + Assert.Equal("mailbox", response.Text); + Assert.Equal("response-id", response.ResponseId); + } + + [Fact] + public async Task PollingExpiredRevisedResultThrowsUnavailableWithoutTranscriptFallbackAsync() + { + DateTimeOffset completedAt = DateTimeOffset.UtcNow.AddMinutes(-2); + DateTimeOffset expiresAt = completedAt.AddMinutes(1); + DurableAgentState state = CreateRevisedState( + resultText: "mailbox", + includeTranscript: true, + completedAt, + expiresAt); + + DurableAgentResultUnavailableException exception = + await Assert.ThrowsAsync( + () => CreateHandle(state).ReadAgentResponseAsync()); + + Assert.Equal("correlation", exception.CorrelationId); + Assert.Equal(completedAt, exception.CompletedAt); + } + + [Fact] + public async Task PollingDuplicateTerminalsThrowsImmediatelyAsync() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add(CreateResponse("correlation", "first")); + state.Data.ConversationHistory.Add(CreateResponse("correlation", "second")); + int readCount = 0; + + DurableAgentStateCorruptionException exception = + await Assert.ThrowsAsync( + () => CreateHandle(state, () => readCount++).ReadAgentResponseAsync()); + + Assert.Equal("correlation", exception.CorrelationId); + Assert.Equal(2, exception.TerminalResponseCount); + Assert.Equal(1, readCount); + } + + [Fact] + public async Task PollingWithoutTerminalRemainsPendingAsync() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CorrelationId = "correlation", + CreatedAt = DateTimeOffset.UtcNow, + }); + using CancellationTokenSource cancellation = new(); + int readCount = 0; + AgentRunHandle handle = CreateHandle( + state, + () => + { + readCount++; + cancellation.Cancel(); + }); + + await Assert.ThrowsAnyAsync( + () => handle.ReadAgentResponseAsync(cancellation.Token)); + + Assert.Equal(1, readCount); + } + + [Fact] + public async Task ClientRejectsInvalidCorrelationBeforeSignallingAsync() + { + Mock client = new(MockBehavior.Strict, "test"); + DefaultDurableAgentClient durableAgentClient = + new(client.Object, NullLoggerFactory.Instance); + RunRequest request = new("request") { CorrelationId = "" }; + + ArgumentException exception = await Assert.ThrowsAsync( + () => durableAgentClient.RunAgentAsync(s_sessionId, request)); + + Assert.Equal("request", exception.ParamName); + client.VerifyNoOtherCalls(); + } + + [Fact] + public void HandleRejectsInvalidCorrelationBeforePolling() + { + Mock client = new("test"); + + ArgumentException exception = Assert.Throws( + () => new AgentRunHandle( + client.Object, + NullLogger.Instance, + s_sessionId, + " ")); + + Assert.Equal("correlationId", exception.ParamName); + } + + private static AgentRunHandle CreateHandle( + DurableAgentState state, + Action? onRead = null, + string correlationId = "correlation", + TimeProvider? timeProvider = null) + { + Mock entities = new("test"); + entities + .Setup(client => client.GetEntityAsync( + s_sessionId, + It.IsAny())) + .Callback(onRead ?? (() => { })) + .ReturnsAsync(new EntityMetadata(s_sessionId, state)); + + Mock client = new("test"); + client.SetupGet(value => value.Entities).Returns(entities.Object); + return new AgentRunHandle( + client.Object, + NullLogger.Instance, + s_sessionId, + correlationId, + timeProvider); + } + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private static DurableAgentState CreateRevisedState( + string resultText, + bool includeTranscript, + DateTimeOffset? completedAt = null, + DateTimeOffset? expiresAt = null) + { + DateTimeOffset completed = completedAt ?? DateTimeOffset.UtcNow; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, resultText)) + { + ResponseId = "response-id", + }; + DurableAgentStateTerminalResult result = + DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + completed, + expiresAt); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = [], + TerminalResults = new Dictionary + { + ["correlation"] = result, + }, + CompletionReceipts = new Dictionary + { + ["correlation"] = new() + { + CorrelationId = "correlation", + Outcome = result.Outcome, + CompletedAt = completed, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + ResultExpiresAt = expiresAt, + }, + }, + }, + }; + if (includeTranscript) + { + state.Data.ConversationHistory.Add( + CreateResponse("correlation", "transcript")); + } + + return state; + } + + private static DurableAgentStateResponse CreateResponse(string correlationId, string text) + { + return new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = DateTimeOffset.UtcNow, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, text)), + ], + }; + } + + private static DurableAgentStateErrorResponse CreateErrorResponse(string correlationId, string text) + { + return new DurableAgentStateErrorResponse + { + CorrelationId = correlationId, + CreatedAt = DateTimeOffset.UtcNow, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, text)), + ], + }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAIAgentProxyTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAIAgentProxyTests.cs index 97b88b4..2412ac3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAIAgentProxyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAIAgentProxyTests.cs @@ -1,11 +1,91 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; namespace Microsoft.Agents.AI.DurableTask.UnitTests; public sealed class DurableAIAgentProxyTests { + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("false")] + [InlineData("""{"nested":[null,0,""]}""")] + public async Task ProxyRetainsCanonicalResultWithoutChangingNativeResponseAsync(string? valueJson) + { + AgentSessionId sessionId = new("agentA", "session"); + DateTimeOffset completedAt = DateTimeOffset.UtcNow; + using JsonDocument metadata = JsonDocument.Parse("""{"preserve":[1,null]}"""); + DurableAgentStateTerminalResponse terminalResponse = new() + { + Messages = [], + ResponseId = "retained-response", + Value = valueJson is null + ? default + : JsonSerializer.Deserialize(valueJson, DurableAgentStateJsonContext.Default.JsonElement), + UnknownProperties = new Dictionary + { + ["futureResponseField"] = metadata.RootElement.Clone(), + }, + Usage = new DurableAgentStateUsage + { + ExtensionData = new Dictionary + { + ["providerObject"] = metadata.RootElement.Clone(), + }, + }, + }; + DurableAgentState state = CreateRevisedState(new DurableAgentStateTerminalResult + { + CorrelationId = "correlation", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + Response = terminalResponse, + }, new DurableAgentStateCompletionReceipt + { + CorrelationId = "correlation", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }); + DurableAIAgentProxy proxy = new("agentA", new HandleDurableAgentClient(CreateHandle(sessionId, state))); + + AgentResponse response = await proxy.RunAsync( + new ChatMessage(ChatRole.User, "request"), new DurableAgentSession(sessionId)); + JsonElement result = Assert.IsType(DurableAgentJsonUtilities.GetRetainedResult(response)); + + Assert.Equal("retained-response", result.GetProperty("responseId").GetString()); + Assert.True(JsonElement.DeepEquals(metadata.RootElement, result.GetProperty("futureResponseField"))); + Assert.True(JsonElement.DeepEquals(metadata.RootElement, result.GetProperty("usage").GetProperty("extensionData").GetProperty("providerObject"))); + Assert.Equal(valueJson is not null, result.TryGetProperty("value", out JsonElement value)); + if (valueJson is not null) + { + Assert.True(JsonElement.DeepEquals(terminalResponse.Value, value)); + } + + JsonElement native = JsonSerializer.SerializeToElement( + response, DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentResponse))); + Assert.False(native.TryGetProperty("value", out _)); + Assert.False(native.TryGetProperty("futureResponseField", out _)); + Assert.Null(response.RawRepresentation); + terminalResponse.UnknownProperties!.Clear(); + Assert.True(Assert.IsType(DurableAgentJsonUtilities.GetRetainedResult(response)) + .TryGetProperty("futureResponseField", out _)); + } + + [Fact] + public void OrdinaryResponseDoesNotAcquireAnInferredDurableResult() + { + Assert.Null(DurableAgentJsonUtilities.GetRetainedResult( + new AgentResponse(new ChatMessage(ChatRole.Assistant, "null")))); + } + // Verifies the proxy rejects a session whose agent name differs from its own, // and that the durable client is never called when this happens. [Fact] @@ -63,6 +143,134 @@ public async Task RunAsync_AgentNameComparisonIsCaseInsensitiveAsync() Assert.Equal(1, client.CallCount); } + [Theory] + [InlineData(DurableAgentStateCompletionReceipt.SucceededOutcome)] + [InlineData(DurableAgentStateCompletionReceipt.FailedOutcome)] + public async Task RunAsync_PropagatesCompletedResultUnavailableAsync(string outcome) + { + AgentSessionId sessionId = new("agentA", "shared-key"); + DurableAgentState state = CreateUnavailableState(outcome); + DurableAIAgentProxy proxy = new( + "agentA", + new HandleDurableAgentClient(CreateHandle(sessionId, state))); + + DurableAgentResultUnavailableException exception = + await Assert.ThrowsAsync( + () => proxy.RunAsync( + new ChatMessage(ChatRole.User, "hello"), + new DurableAgentSession(sessionId))); + + Assert.NotNull(exception.CompletedAt); + Assert.Equal(outcome, exception.Outcome); + } + + [Fact] + public async Task RunAsync_PropagatesTerminalErrorMetadataAsync() + { + AgentSessionId sessionId = new("agentA", "shared-key"); + DurableAgentState state = CreateFailedState(); + DurableAIAgentProxy proxy = new( + "agentA", + new HandleDurableAgentClient(CreateHandle(sessionId, state))); + + DurableAgentTerminalException exception = + await Assert.ThrowsAsync( + () => proxy.RunAsync( + new ChatMessage(ChatRole.User, "hello"), + new DurableAgentSession(sessionId))); + + Assert.Equal("Example", exception.Code); + Assert.Equal("recorded failure", exception.Message); + Assert.Equal("failed response", exception.Response?.Text); + } + + private static AgentRunHandle CreateHandle( + AgentSessionId sessionId, + DurableAgentState state) + { + Mock entities = new("test"); + entities.Setup(client => client.GetEntityAsync( + sessionId, + It.IsAny())) + .ReturnsAsync(new EntityMetadata(sessionId, state)); + Mock client = new("test"); + client.SetupGet(value => value.Entities).Returns(entities.Object); + return new AgentRunHandle( + client.Object, + NullLogger.Instance, + sessionId, + "correlation"); + } + + private static DurableAgentState CreateUnavailableState(string outcome) + { + DateTimeOffset completedAt = DateTimeOffset.UtcNow.AddMinutes(-1); + return CreateRevisedState( + terminalResult: null, + new DurableAgentStateCompletionReceipt + { + CorrelationId = "correlation", + Outcome = outcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.UnavailableResult, + ResultUnavailableAt = completedAt.AddSeconds(1), + }); + } + + private static DurableAgentState CreateFailedState() + { + DateTimeOffset completedAt = DateTimeOffset.UtcNow; + DurableAgentStateTerminalResult result = new() + { + CorrelationId = "correlation", + Outcome = DurableAgentStateCompletionReceipt.FailedOutcome, + CompletedAt = completedAt, + Response = DurableAgentStateTerminalResponse.FromResponse( + new AgentResponse(new ChatMessage(ChatRole.Assistant, "failed response")), + "correlation", + completedAt), + Error = new DurableAgentStateTerminalError + { + Code = "Example", + Message = "recorded failure", + }, + }; + return CreateRevisedState( + result, + new DurableAgentStateCompletionReceipt + { + CorrelationId = "correlation", + Outcome = result.Outcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }); + } + + private static DurableAgentState CreateRevisedState( + DurableAgentStateTerminalResult? terminalResult, + DurableAgentStateCompletionReceipt receipt) + { + return new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = [], + TerminalResults = terminalResult is null + ? new Dictionary() + : new Dictionary + { + ["correlation"] = terminalResult, + }, + CompletionReceipts = + new Dictionary + { + ["correlation"] = receipt, + }, + }, + }; + } + private sealed class StubDurableAgentClient : IDurableAgentClient { public int CallCount { get; private set; } @@ -84,4 +292,13 @@ public Task RunAgentAsync( throw new InvalidOperationException("Test did not configure a response."); } } + + private sealed class HandleDurableAgentClient(AgentRunHandle handle) : IDurableAgentClient + { + public Task RunAgentAsync( + AgentSessionId sessionId, + RunRequest request, + CancellationToken cancellationToken) => + Task.FromResult(handle); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentFailureDeliveryTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentFailureDeliveryTests.cs new file mode 100644 index 0000000..f8f0f67 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentFailureDeliveryTests.cs @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableAgentFailureDeliveryTests +{ + private const string FailureText = """{"result":"not success","haltRequested":false,"sentMessages":[{"data":"must not route"}]}"""; + private static readonly DateTimeOffset s_completedAt = new(2026, 9, 10, 0, 0, 0, TimeSpan.Zero); + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task DirectOrchestrationPreservesCommittedFailureAcrossSdkSerializationAsync(bool legacy, bool entityOperationFailure) + { + FailureBoundary boundary = new(legacy, entityOperationFailure); + DurableAIAgent agent = boundary.Context.Object.GetAgent("agent"); + + DurableAgentTerminalException exception = await Assert.ThrowsAsync( + () => agent.RunAsync([], new DurableAgentSession(new AgentSessionId("agent", "session")))); + + AssertFailure(exception, legacy); + Exception transportCause = Assert.IsType(exception.InnerException).InnerException!; + Assert.Equal(entityOperationFailure ? typeof(EntityOperationFailedException) : typeof(TaskFailedException), transportCause.GetType()); + Assert.True(DurableAgentFailure.TryRestore(WrapSdkFailure(exception, entityOperationFailure), out Exception? restored)); + AssertFailure(Assert.IsType(restored), legacy); + boundary.AssertUnchanged(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task DispatcherDoesNotReturnSuccessfulOutputForCommittedFailureAsync(bool legacy, bool entityOperationFailure) + { + FailureBoundary boundary = new(legacy, entityOperationFailure); + + DurableAgentTerminalException exception = await Assert.ThrowsAsync( + () => DurableExecutorDispatcher.DispatchAsync( + boundary.Context.Object, + new WorkflowExecutorInfo("agent", IsAgenticExecutor: true), + new DurableMessageEnvelope { Message = "retry" }, + [], new DurableWorkflowLiveStatus(), NullLogger.Instance)); + + AssertFailure(exception, legacy); + boundary.AssertUnchanged(); + } + + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("false")] + [InlineData("0")] + [InlineData("\"\"")] + [InlineData("{}")] + public void FailureDetailsRetainAbsentNullAndFalsyValuesAcrossSdkSerialization(string? detailsJson) + { + DurableAgentRunOutcome outcome = DurableAgentStateOutcomeResolver.Resolve( + CreateCommittedState("correlation", legacy: false, unavailableOutcome: null), "correlation", s_completedAt); + DurableAgentTerminalException original = new( + "correlation", "CommittedFailure", "error", + detailsJson is null ? null : JsonSerializer.Deserialize(detailsJson), outcome.Response!); + + Assert.True(DurableAgentFailure.TryRestore(WrapSdkFailure(original, entityOperationFailure: false), out Exception? restored)); + DurableAgentTerminalException terminal = Assert.IsType(restored); + Assert.Equal(detailsJson is null, terminal.Details is null); + if (detailsJson is not null) + { + Assert.True(JsonElement.DeepEquals( + JsonSerializer.Deserialize(detailsJson), Assert.IsType(terminal.Details))); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OrdinarySdkFailureIsNotReclassifiedFromMessageTextAsync(bool entityOperationFailure) + { + DurableAgentTerminalException terminal = new("correlation", "code", "error", null, new AgentResponse([])); + Exception transport = Assert.IsType(terminal.InnerException); + Exception sdkFailure = WrapSdkFailure(new InvalidOperationException(transport.Message, transport), entityOperationFailure); + Mock context = CreateFailingContext(sdkFailure); + + Exception propagated = await Assert.ThrowsAnyAsync( + () => context.Object.GetAgent("agent").RunAsync([], new DurableAgentSession(new AgentSessionId("agent", "session")))); + + Assert.Same(sdkFailure, propagated); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task HistoricalMessageOnlyTerminalFailuresStayTypedWithoutParsingTheirMessageAsync(bool entityOperationFailure) + { + Exception sdkFailure = WrapSdkFailure(new DurableAgentTerminalException(FailureText), entityOperationFailure); + Mock context = CreateFailingContext(sdkFailure); + + DurableAgentTerminalException propagated = await Assert.ThrowsAsync( + () => context.Object.GetAgent("agent").RunAsync([], new DurableAgentSession(new AgentSessionId("agent", "session")))); + + Assert.Equal(FailureText, propagated.Message); + Assert.Null(propagated.Code); + Assert.Null(propagated.Details); + Assert.Null(propagated.Response); + Assert.Same(sdkFailure, propagated.InnerException); + } + + [Theory] + [InlineData("not JSON")] + [InlineData("""{"version":2,"correlationId":"correlation"}""")] + [InlineData("""{"version":1,"correlationId":"correlation"}""")] + [InlineData("""{"version":1,"correlationId":"correlation","code":"error","serializedResponse":"null"}""")] + [InlineData("""{"version":1,"correlationId":"correlation","code":"error","serializedResponse":"invalid JSON"}""")] + public async Task InvalidFrameworkMetadataLeavesSdkFailureIntactAsync(string metadata) + { + Exception sdkFailure = WrapSdkFailure( + new DurableAgentTerminalException("recorded error", new DurableAgentFailureMetadataException(metadata)), entityOperationFailure: false); + Mock context = CreateFailingContext(sdkFailure); + + Exception propagated = await Assert.ThrowsAnyAsync( + () => context.Object.GetAgent("agent").RunAsync([], new DurableAgentSession(new AgentSessionId("agent", "session")))); + + Assert.Same(sdkFailure, propagated); + } + + private static Mock CreateFailingContext(Exception failure) + { + Mock entities = new(); + entities.Setup(value => value.CallEntityAsync( + It.IsAny(), nameof(AgentEntity.Run), It.IsAny(), It.IsAny())) + .ThrowsAsync(failure); + Mock context = new(); + context.SetupGet(value => value.Entities).Returns(entities.Object); + context.SetupGet(value => value.InstanceId).Returns("failure-orchestration"); + return context; + } + + private static Exception WrapSdkFailure(Exception exception, bool entityOperationFailure) + { + // Use the SDK's lossy exception projection, then discard all CLR exception identity + // across a JSON hop. Custom exception properties are not transported by the SDK. + TaskFailureDetails details = TaskFailureDetails.FromException(exception); + Assert.Null(details.Properties); + TaskFailureDetails restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(details))!; + return entityOperationFailure + ? new EntityOperationFailedException(new AgentSessionId("agent", "session"), nameof(AgentEntity.Run), restored) + : new TaskFailedException(nameof(AgentEntity.Run), 1, restored); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task RunnerDoesNotRouteCommittedFailureToDownstreamExecutorAsync(bool legacy, bool entityOperationFailure) + { + FailureBoundary boundary = new(legacy, entityOperationFailure); + Mock agent = new(); + agent.SetupGet(value => value.Name).Returns("agent"); + FunctionExecutor downstream = new("downstream", (input, _, _) => input, outputTypes: [typeof(string)]); + Workflow workflow = new WorkflowBuilder(agent.Object).WithName("FailureWorkflow") + .AddEdge(agent.Object, downstream).Build(); + DurableOptions options = new(); + options.Workflows.AddWorkflow(workflow); + boundary.Context.SetupGet(value => value.Name) + .Returns(WorkflowNamingHelper.ToOrchestrationFunctionName("FailureWorkflow")); + boundary.Context.Setup(value => value.CallActivityAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("""{"result":"downstream success"}"""); + + DurableAgentTerminalException exception = await Assert.ThrowsAsync( + () => new DurableWorkflowRunner(options).RunWorkflowOrchestrationAsync( + boundary.Context.Object, new DurableWorkflowInput { Input = "retry" }, NullLogger.Instance)); + + AssertFailure(exception, legacy); + boundary.Context.Verify(value => value.CallActivityAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + boundary.AssertUnchanged(); + } + + [Theory] + [InlineData(false, DurableAgentStateCompletionReceipt.SucceededOutcome)] + [InlineData(false, DurableAgentStateCompletionReceipt.FailedOutcome)] + [InlineData(true, DurableAgentStateCompletionReceipt.SucceededOutcome)] + [InlineData(true, DurableAgentStateCompletionReceipt.FailedOutcome)] + public async Task DirectOrchestrationPreservesUnavailableOutcomeAcrossSdkSerializationAsync(bool entityOperationFailure, string outcome) + { + FailureBoundary boundary = new(legacy: false, entityOperationFailure, unavailableOutcome: outcome); + DurableAIAgent agent = boundary.Context.Object.GetAgent("agent"); + + DurableAgentResultUnavailableException exception = await Assert.ThrowsAsync( + () => agent.RunAsync([], new DurableAgentSession(new AgentSessionId("agent", "session")))); + + Assert.Equal(boundary.CorrelationId, exception.CorrelationId); + Assert.Equal(outcome, exception.Outcome); + Assert.Equal(s_completedAt, exception.CompletedAt); + Assert.Equal(s_completedAt.AddMinutes(1), exception.ResultExpiresAt); + boundary.AssertUnchanged(); + } + + private static void AssertFailure(DurableAgentTerminalException exception, bool legacy) + { + Assert.False(string.IsNullOrEmpty(exception.CorrelationId)); + Assert.Equal(legacy ? "legacyErrorResponse" : "CommittedFailure", exception.Code); + Assert.Equal(legacy + ? "The durable agent request completed with a recorded terminal error." + : "recorded error, not inferred from response text", exception.Message); + Assert.Equal(FailureText, exception.Response?.Text); + JsonElement result = Assert.IsType(exception.Response!.GetDurableResult()); + Assert.Equal(JsonValueKind.Array, result.GetProperty("messages").ValueKind); + if (legacy) + { + Assert.Null(exception.Details); + } + else + { + Assert.True(JsonElement.DeepEquals( + JsonSerializer.Deserialize("""{"reason":{"empty":"","false":false,"zero":0,"null":null}}"""), + Assert.IsType(exception.Details))); + Assert.Equal(JsonValueKind.Null, result.GetProperty("value").ValueKind); + Assert.Equal(JsonValueKind.Object, result.GetProperty("futureMetadata").ValueKind); + } + } + + private sealed class FailureBoundary + { + private readonly bool _legacy; + private readonly bool _entityOperationFailure; + private readonly string? _unavailableOutcome; + private DurableAgentState? _state; + private string? _serializedState; + private AgentEntityDeliveryTests.EntityHarness? _entity; + private readonly AgentEntityDeliveryTests.RecordingAgent _agent = new("agent"); + private int _factoryCalls; + private int _entityCalls; + + public FailureBoundary(bool legacy, bool entityOperationFailure, string? unavailableOutcome = null) + { + this._legacy = legacy; + this._entityOperationFailure = entityOperationFailure; + this._unavailableOutcome = unavailableOutcome; + Mock entities = new(); + entities.Setup(value => value.CallEntityAsync( + It.IsAny(), nameof(AgentEntity.Run), It.IsAny(), It.IsAny())) + .Returns((EntityInstanceId id, string _, object? input, CallEntityOptions? _) => + this.InvokeEntityAsync(Assert.IsType(input))); + this.Context.SetupGet(value => value.Entities).Returns(entities.Object); + this.Context.SetupGet(value => value.InstanceId).Returns("failure-orchestration"); + this.Context.Setup(value => value.NewGuid()).Returns(Guid.Parse("d9a9751e-30fd-4f7a-95f8-f522b4e76977")); + } + + public Mock Context { get; } = new(); + + public string? CorrelationId { get; private set; } + + public void AssertUnchanged() + { + Assert.Equal(1, this._entityCalls); + Assert.Equal(0, this._factoryCalls); + Assert.Equal(0, this._agent.InvocationCount); + Assert.False(this._entity!.StateWasPersisted); + Assert.Equal(this._serializedState, JsonSerializer.Serialize(this._state, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + private async Task InvokeEntityAsync(RunRequest input) + { + this._entityCalls++; + DurableDataConverter converter = new(); + RunRequest request = Assert.IsType(converter.Deserialize(converter.Serialize(input), typeof(RunRequest))); + this.CorrelationId = request.CorrelationId; + this._state = CreateCommittedState(request.CorrelationId, this._legacy, this._unavailableOutcome); + this._serializedState = JsonSerializer.Serialize(this._state, DurableAgentStateJsonContext.Default.DurableAgentState); + this._entity = AgentEntityDeliveryTests.CreateHarness( + this._agent, this._state, registerWithFactory: true, onFactoryInvoked: () => this._factoryCalls++, + enableMailboxWrites: false, authorizeLegacyMigration: false); + try + { + AgentResponse response = await this._entity.RunAsync(request); + return Assert.IsType(converter.Deserialize(converter.Serialize(response), typeof(AgentResponse))); + } + catch (Exception exception) + { + throw WrapSdkFailure(exception, this._entityOperationFailure); + } + } + } + + private static DurableAgentState CreateCommittedState(string correlationId, bool legacy, string? unavailableOutcome) + { + DurableAgentStateMessage message = DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.Assistant, FailureText)); + if (legacy) + { + return new DurableAgentState + { + Data = new DurableAgentStateData + { + ConversationHistory = + [ + new DurableAgentStateErrorResponse { CorrelationId = correlationId, CreatedAt = s_completedAt, Messages = [message] }, + ], + }, + }; + } + + string outcome = unavailableOutcome ?? DurableAgentStateCompletionReceipt.FailedOutcome; + return new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + // Contradictory transcript text cannot override schema-2 completion evidence. + ConversationHistory = + [ + new DurableAgentStateResponse { CorrelationId = correlationId, CreatedAt = s_completedAt, Messages = [message] }, + ], + TerminalResults = unavailableOutcome is not null ? [] : new Dictionary + { + [correlationId] = new() + { + CorrelationId = correlationId, + Outcome = outcome, + CompletedAt = s_completedAt, + Response = new DurableAgentStateTerminalResponse + { + Messages = [message], + Value = JsonSerializer.Deserialize("null"), + UnknownProperties = new Dictionary + { + ["futureMetadata"] = JsonSerializer.Deserialize("{}"), + }, + }, + Error = new DurableAgentStateTerminalError + { + Code = "CommittedFailure", + Message = "recorded error, not inferred from response text", + Details = JsonSerializer.Deserialize("""{"reason":{"empty":"","false":false,"zero":0,"null":null}}"""), + }, + }, + }, + CompletionReceipts = new Dictionary + { + [correlationId] = new() + { + CorrelationId = correlationId, + Outcome = outcome, + CompletedAt = s_completedAt, + ResultState = unavailableOutcome is null + ? DurableAgentStateCompletionReceipt.AvailableResult + : DurableAgentStateCompletionReceipt.UnavailableResult, + ResultExpiresAt = unavailableOutcome is null ? null : s_completedAt.AddMinutes(1), + ResultUnavailableAt = unavailableOutcome is null ? null : s_completedAt.AddMinutes(1), + }, + }, + }, + }; + } +} 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..b153b28 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentHistoryOwnershipTests.cs @@ -0,0 +1,466 @@ +// 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 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 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/DurableAgentResponseSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentResponseSerializationTests.cs new file mode 100644 index 0000000..1d0559a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentResponseSerializationTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableAgentResponseSerializationTests +{ + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("false")] + [InlineData("0")] + [InlineData("\"\"")] + [InlineData("{}")] + [InlineData("""{"nested":[null,0,""]}""")] + public void DurableResponseRoundTripPreservesCanonicalResultAndNativeShape(string? valueJson) + { + AgentResponse response = CreateResponse(valueJson); + JsonElement expected = Assert.IsType(response.GetDurableResult()); + JsonElement nativeBefore = SerializeNative(response); + DurableDataConverter converter = new(); + + string wire = converter.Serialize(response); + AgentResponse restored = Assert.IsType(converter.Deserialize(wire, typeof(AgentResponse))); + JsonElement actual = Assert.IsType(restored.GetDurableResult()); + + Assert.NotSame(response, restored); + Assert.True(JsonElement.DeepEquals(expected, actual)); + Assert.True(JsonElement.DeepEquals(nativeBefore, SerializeNative(restored))); + Assert.Equal(valueJson is not null, actual.TryGetProperty("value", out _)); + Assert.True(actual.GetProperty("futureResponseField").GetProperty("preserve").GetBoolean()); + Assert.Null(restored.RawRepresentation); + } + + [Fact] + public async Task OrchestrationCallReceivesCanonicalResultAfterDurableWireRoundTripAsync() + { + DurableDataConverter converter = new(); + string wire = converter.Serialize(CreateResponse("null")); + AgentSessionId sessionId = new("agent", "session"); + Mock entities = new(); + entities.Setup(value => value.CallEntityAsync( + sessionId, nameof(AgentEntity.Run), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => Assert.IsType(converter.Deserialize(wire, typeof(AgentResponse)))); + Mock context = new(); + context.SetupGet(value => value.Entities).Returns(entities.Object); + context.SetupGet(value => value.InstanceId).Returns("orchestration"); + DurableAIAgent agent = new(context.Object, "agent"); + + AgentResponse response = await agent.RunAsync( + new ChatMessage(ChatRole.User, "request"), new DurableAgentSession(sessionId)); + + JsonElement result = Assert.IsType(response.GetDurableResult()); + Assert.Equal(JsonValueKind.Null, result.GetProperty("value").ValueKind); + Assert.True(result.GetProperty("futureResponseField").GetProperty("preserve").GetBoolean()); + } + + [Fact] + public void SharedOpaqueUriSurvivesDurableResponseSerializationWithoutInventedMediaType() + { + DurableAgentState state = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0-lossless.json")), + DurableAgentStateJsonContext.Default.DurableAgentState)!; + AgentResponse response = DurableAgentStateOutcomeResolver.Resolve( + state, "corr-lossless", DateTimeOffset.UtcNow).Response!; + DurableDataConverter converter = new(); + + AgentResponse restored = Assert.IsType( + converter.Deserialize(converter.Serialize(response), typeof(AgentResponse))); + + JsonElement result = Assert.IsType(restored.GetDurableResult()); + JsonElement uri = result.GetProperty("messages")[0].GetProperty("contents")[1]; + Assert.Equal("uri", uri.GetProperty("$type").GetString()); + Assert.False(uri.TryGetProperty("mediaType", out _)); + Assert.Equal(JsonValueKind.False, result.GetProperty("value").ValueKind); + } + + [Theory] + [InlineData("""{"kind":"agentResponse","version":2,"result":{"messages":[]}}""")] + [InlineData("""{"kind":"unknown","version":1,"result":{"messages":[]}}""")] + [InlineData("""{"kind":"agentResponse","version":1,"result":null}""")] + [InlineData("""{"kind":"agentResponse","version":1,"result":{"value":null}}""")] + [InlineData("""{"kind":"agentResponse","version":1,"version":1,"result":{"messages":[]}}""")] + public void MalformedOrUnsupportedDurableResponseEnvelopeFailsClosed(string envelope) + { + string wire = """{"messages":[],"$microsoftAgentFrameworkDurableTask":ENVELOPE}""" + .Replace("ENVELOPE", envelope, StringComparison.Ordinal); + + Assert.Throws(() => new DurableDataConverter().Deserialize(wire, typeof(AgentResponse))); + } + + [Fact] + public void LegacyNativeResponseRemainsReadableWithoutFabricatingCanonicalMetadata() + { + DurableDataConverter converter = new(); + AgentResponse source = new(new ChatMessage(ChatRole.Assistant, "null")); + + string wire = converter.Serialize(source); + AgentResponse restored = Assert.IsType(converter.Deserialize(wire, typeof(AgentResponse))); + + Assert.Equal("null", restored.Text); + Assert.Null(restored.GetDurableResult()); + Assert.DoesNotContain("$microsoftAgentFrameworkDurableTask", wire, StringComparison.Ordinal); + } + + private static JsonElement SerializeNative(AgentResponse response) => + JsonSerializer.SerializeToElement(response, DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentResponse))); + + private static AgentResponse CreateResponse(string? valueJson) + { + DateTimeOffset completedAt = DateTimeOffset.UtcNow; + DurableAgentStateTerminalResult result = DurableAgentStateTerminalResult.FromResponse( + "correlation", new AgentResponse(new ChatMessage(ChatRole.Assistant, "native text")), + completedAt, structuredValue: valueJson is null + ? default + : JsonSerializer.Deserialize(valueJson, DurableAgentStateJsonContext.Default.JsonElement)); + result.Response!.UnknownProperties = new Dictionary + { + ["futureResponseField"] = JsonSerializer.SerializeToElement(new { preserve = true }), + }; + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary { ["correlation"] = result }, + CompletionReceipts = new Dictionary + { + ["correlation"] = new() + { + CorrelationId = "correlation", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }, + }, + }, + }; + return DurableAgentStateOutcomeResolver.Resolve(state, "correlation", completedAt).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/DurableAgentStateOutcomeResolverTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentStateOutcomeResolverTests.cs new file mode 100644 index 0000000..0c7bb59 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentStateOutcomeResolverTests.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableAgentStateOutcomeResolverTests +{ + private static readonly DateTimeOffset s_completedAt = + new(2026, 9, 10, 5, 0, 0, TimeSpan.Zero); + + [Fact] + public void LegacySuccessAndErrorResolveFromTranscript() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add(CreateLegacyResponse("success", "answer")); + state.Data.ConversationHistory.Add(CreateLegacyResponse("failure", "error", isError: true)); + + DurableAgentRunOutcome success = + DurableAgentStateOutcomeResolver.Resolve(state, "success", s_completedAt); + DurableAgentRunOutcome failure = + DurableAgentStateOutcomeResolver.Resolve(state, "failure", s_completedAt); + + Assert.Equal(DurableAgentRunOutcomeKind.Succeeded, success.Kind); + Assert.Equal("answer", success.Response?.Text); + Assert.Equal(DurableAgentRunOutcomeKind.Failed, failure.Kind); + Assert.Equal("legacyErrorResponse", failure.Error?.Code); + Assert.Equal("error", failure.Response?.Text); + } + + [Fact] + public void RevisedMailboxIsAuthoritativeAfterTranscriptRemoval() + { + DurableAgentState state = CreateRevisedState(); + AddResult(state, "correlation", "mailbox"); + state.Data.ConversationHistory.Add(CreateLegacyResponse("correlation", "transcript")); + + state.Data.ConversationHistory.Clear(); + DurableAgentRunOutcome outcome = + DurableAgentStateOutcomeResolver.Resolve(state, "correlation", s_completedAt); + + Assert.Equal(DurableAgentRunOutcomeKind.Succeeded, outcome.Kind); + Assert.Equal("mailbox", outcome.Response?.Text); + } + + [Fact] + public void RevisedMissingReceiptNeverFallsBackToTranscript() + { + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add(CreateLegacyResponse("correlation", "transcript")); + + DurableAgentRunOutcome outcome = + DurableAgentStateOutcomeResolver.Resolve(state, "correlation", s_completedAt); + + Assert.Equal(DurableAgentRunOutcomeKind.Pending, outcome.Kind); + } + + [Fact] + public void ExpiredRevisedPayloadResolvesCompletedUnavailableWithoutTranscriptFallback() + { + DurableAgentState state = CreateRevisedState(); + AddResult( + state, + "correlation", + "mailbox", + resultExpiresAt: s_completedAt.AddMinutes(1)); + state.Data.ConversationHistory.Add(CreateLegacyResponse("correlation", "transcript")); + + DurableAgentRunOutcome outcome = DurableAgentStateOutcomeResolver.Resolve( + state, + "correlation", + s_completedAt.AddMinutes(1)); + + Assert.Equal(DurableAgentRunOutcomeKind.CompletedResultUnavailable, outcome.Kind); + Assert.Equal(s_completedAt, outcome.Receipt?.CompletedAt); + } + + [Fact] + public void InconsistentRevisedResultAndReceiptFailsClosed() + { + DurableAgentState state = CreateRevisedState(); + AddResult(state, "correlation", "mailbox"); + state.Data.CompletionReceipts!.Remove("correlation"); + + Assert.Throws( + () => DurableAgentStateOutcomeResolver.Resolve( + state, + "correlation", + s_completedAt)); + } + + [Fact] + public void LegacyMigrationConvertsAllEvidenceAndIsIdempotent() + { + DurableAgentState legacy = new(); + legacy.Data.ConversationHistory.Add(CreateLegacyResponse("success", "answer")); + legacy.Data.ConversationHistory.Add(CreateLegacyResponse("failure", "error", isError: true)); + + DurableAgentState first = + DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState(legacy, hasAuthoritativeLegacyHistory: true); + DurableAgentState second = + DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState(first); + + Assert.Equal(DurableAgentState.RevisedSchemaVersion, first.SchemaVersion); + Assert.Equal(2, first.Data.TerminalResults?.Count); + Assert.Equal(2, first.Data.CompletionReceipts?.Count); + Assert.Equal( + DurableAgentStateCompletionReceipt.FailedOutcome, + first.Data.TerminalResults?["failure"].Outcome); + Assert.Equal("legacyErrorResponse", first.Data.TerminalResults?["failure"].Error?.Code); + Assert.Equal("answer", first.Data.TerminalResults?["success"].Response?.ToResponse().Text); + Assert.Equal(2, second.Data.TerminalResults?.Count); + Assert.Equal(2, second.Data.CompletionReceipts?.Count); + + first.Data.ConversationHistory + .OfType() + .First(response => response.CorrelationId == "success") + .Messages[0].MessageId = "transcript-mutated"; + Assert.Equal( + "durable_response_success_0", + first.Data.TerminalResults?["success"].Response?.Messages[0].MessageId); + } + + [Fact] + public void PrunedLegacyStateCannotBecomeAnEmptyMailboxEvenWithAuthorization() + { + DurableAgentState legacy = new() + { + Data = new DurableAgentStateData + { + ConversationHistory = + [ + new DurableAgentStateRequest { CorrelationId = "evicted", CreatedAt = s_completedAt }, + new DurableAgentStateResponse(), + ], + Truncation = new DurableAgentStateTruncation + { + EvictedMessageCount = 2, + FirstEvictedAt = s_completedAt, + LastEvictedAt = s_completedAt, + }, + }, + }; + + Assert.Throws(() => + DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState(legacy, hasAuthoritativeLegacyHistory: true)); + Assert.Null(legacy.Data.CompletionReceipts); + Assert.Null(legacy.Data.TerminalResults); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LegacyMigrationRequiresIndependentEvidenceEvenWhenTranscriptLooksComplete(bool hasRetainedResponse) + { + DurableAgentState legacy = new(); + if (hasRetainedResponse) + { + legacy.Data.ConversationHistory.Add(CreateLegacyResponse("old", "retained")); + } + + Assert.Throws(() => + DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState(legacy)); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, legacy.SchemaVersion); + Assert.Null(legacy.Data.CompletionReceipts); + } + + [Fact] + public void ReceiptWithMissingPayloadFailsClosedDespiteLegacyTranscript() + { + DurableAgentState state = CreateRevisedState(); + AddResult(state, "correlation", "mailbox"); + state.Data.TerminalResults!.Clear(); + state.Data.ConversationHistory.Add(CreateLegacyResponse("correlation", "stale")); + + Assert.Throws( + () => DurableAgentStateOutcomeResolver.Resolve(state, "correlation", s_completedAt)); + } + + [Theory] + [InlineData("2.1.0")] + [InlineData("3.0.0")] + [InlineData("2.0.0-preview")] + public void UnknownVersionFailsClosedBeforeDelivery(string version) + { + DurableAgentState state = new() { SchemaVersion = version }; + state.Data.ConversationHistory.Add(CreateLegacyResponse("correlation", "stale")); + + Assert.Throws( + () => DurableAgentStateOutcomeResolver.Resolve(state, "correlation", s_completedAt)); + } + + [Theory] + [InlineData(DurableAgentStateCompletionReceipt.SucceededOutcome)] + [InlineData(DurableAgentStateCompletionReceipt.FailedOutcome)] + public void UnavailablePayloadRetainsItsTerminalOutcome(string outcome) + { + DurableAgentState state = CreateRevisedState(); + state.Data.CompletionReceipts!.Add("correlation", new() + { + CorrelationId = "correlation", + Outcome = outcome, + CompletedAt = s_completedAt, + ResultState = DurableAgentStateCompletionReceipt.UnavailableResult, + ResultUnavailableAt = s_completedAt, + }); + + DurableAgentRunOutcome resolved = DurableAgentStateOutcomeResolver.Resolve(state, "correlation", s_completedAt); + + Assert.Equal(DurableAgentRunOutcomeKind.CompletedResultUnavailable, resolved.Kind); + Assert.Equal(outcome, resolved.Receipt!.Outcome); + } + + [Theory] + [InlineData(null, JsonValueKind.Undefined)] + [InlineData("null", JsonValueKind.Null)] + [InlineData("false", JsonValueKind.False)] + public void ProductionHydrationKeepsAbsentAndExplicitValueSeparate(string? jsonValue, JsonValueKind expectedKind) + { + string valueProperty = jsonValue is null ? string.Empty : $",\"value\":{jsonValue}"; + const string JsonTemplate = """ + {"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{ + "correlation":{"correlationId":"correlation","outcome":"succeeded","completedAt":"2026-09-10T05:00:00Z", + "response":{"messages":[]VALUE_PROPERTY}}}, + "completionReceipts":{"correlation":{"correlationId":"correlation","outcome":"succeeded", + "completedAt":"2026-09-10T05:00:00Z","resultState":"available"}}}} + """; + string json = JsonTemplate.Replace("VALUE_PROPERTY", valueProperty, StringComparison.Ordinal); + DurableAgentState state = JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)!; + + DurableAgentRunOutcome resolved = DurableAgentStateOutcomeResolver.Resolve(state, "correlation", s_completedAt); + + Assert.Equal(expectedKind, resolved.Value.ValueKind); + } + + [Fact] + public void LegacyDuplicateTerminalConversionFailsClosed() + { + DurableAgentState legacy = new(); + legacy.Data.ConversationHistory.Add(CreateLegacyResponse("duplicate", "first")); + legacy.Data.ConversationHistory.Add(CreateLegacyResponse("duplicate", "second")); + + DurableAgentStateCorruptionException exception = + Assert.Throws( + () => DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState(legacy, hasAuthoritativeLegacyHistory: true)); + + Assert.Equal("duplicate", exception.CorrelationId); + Assert.Equal(2, exception.TerminalResponseCount); + } + + [Fact] + public void MarkExpiredResultUnavailablePreservesReceiptAndRemovesPayload() + { + DurableAgentState state = CreateRevisedState(); + AddResult( + state, + "correlation", + "mailbox", + resultExpiresAt: s_completedAt.AddMinutes(1)); + + bool changed = DurableAgentStateOutcomeResolver.MarkExpiredResultUnavailable( + state, + "correlation", + s_completedAt.AddMinutes(2)); + + Assert.True(changed); + Assert.False(state.Data.TerminalResults!.ContainsKey("correlation")); + DurableAgentStateCompletionReceipt receipt = + Assert.IsType( + state.Data.CompletionReceipts!["correlation"]); + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, receipt.ResultState); + Assert.Equal(s_completedAt.AddMinutes(2), receipt.ResultUnavailableAt); + } + + private static DurableAgentState CreateRevisedState() + { + return new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new DurableAgentStateData + { + ConversationHistory = [], + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + }, + }; + } + + private static void AddResult( + DurableAgentState state, + string correlationId, + string text, + DateTimeOffset? resultExpiresAt = null) + { + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, text)) + { + ResponseId = "response-id", + AgentId = "agent-id", + }; + DurableAgentStateTerminalResult result = + DurableAgentStateTerminalResult.FromResponse( + correlationId, + response, + s_completedAt, + resultExpiresAt); + state.Data.TerminalResults!.Add(correlationId, result); + state.Data.CompletionReceipts!.Add( + correlationId, + new DurableAgentStateCompletionReceipt + { + CorrelationId = correlationId, + Outcome = result.Outcome, + CompletedAt = result.CompletedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + ResultExpiresAt = result.ResultExpiresAt, + }); + } + + private static DurableAgentStateResponse CreateLegacyResponse( + string correlationId, + string text, + bool isError = false) + { + IReadOnlyList messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, text)), + ]; + return isError + ? new DurableAgentStateErrorResponse + { + CorrelationId = correlationId, + CreatedAt = s_completedAt, + Messages = messages, + } + : new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = s_completedAt, + Messages = messages, + }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentStateRetentionTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentStateRetentionTests.cs new file mode 100644 index 0000000..7e18861 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentStateRetentionTests.cs @@ -0,0 +1,865 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableAgentStateRetentionTests +{ + [Fact] + public void KeepAllNeverDeletes() + { + DurableAgentState state = CreateLargeState(); + int originalCount = state.Data.ConversationHistory.Count; + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.KeepAll, + 500, + DateTimeOffset.UtcNow, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.Equal(0, removed); + Assert.Equal(originalCount, state.Data.ConversationHistory.Count); + } + + [Fact] + public void DefaultRetentionModeIsKeepAll() + { + DurableAgentsOptions options = new(); + + Assert.Equal( + DurableAgentHistoryRetentionMode.KeepAll, + options.HistoryRetentionMode); + Assert.Equal(1_048_576, options.MaxStateBytes); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void StateBudgetMustBePositive(int value) + { + DurableAgentsOptions options = new(); + + _ = Assert.Throws( + () => options.MaxStateBytes = value); + } + + [Fact] + public void UndefinedRetentionModeIsRejected() + { + DurableAgentsOptions options = new(); + const DurableAgentHistoryRetentionMode Invalid = + (DurableAgentHistoryRetentionMode)42; + + _ = Assert.Throws( + () => options.HistoryRetentionMode = Invalid); + _ = Assert.Throws( + () => DurableAgentStateRetention.Enforce( + CreateRevisedState(), + Invalid, + 1_000, + DateTimeOffset.UtcNow, + NullLogger.Instance, + new AgentSessionId("agent", "session"))); + } + + [Fact] + public void AutoEvictsOldestExchangeAndRecordsBoundedEvidence() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateLargeState(now); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_500, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.True(removed > 0); + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "oldest"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + Assert.NotNull(state.Data.Truncation); + Assert.Equal(removed, state.Data.Truncation.EvictedMessageCount); + Assert.True( + DurableAgentStateRetention.GetSerializedSize(state) < + 2_500 * DurableAgentStateRetention.HighWatermark); + } + + [Fact] + public void AutoPreservesSystemExchange() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateLargeState(now); + state.Data.ConversationHistory.Insert( + 0, + CreateRequest("system", ChatRole.System, new string('s', 500), now.AddMinutes(-10))); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 3_200, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "system"); + } + + [Fact] + public void AutoPreservesNewestExchange() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateLargeState(now); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_500, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + } + + [Fact] + public void AutoPreservesMailboxResultWhileEvictingTranscript() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "old", new string('o', 400), now.AddMinutes(-5)); + AddExchange(state, "completed", new string('a', 400), now.AddSeconds(-30)); + AddExchange(state, "newest", new string('b', 400), now); + AddMailboxResult(state, "completed", "authoritative result", now.AddSeconds(-30)); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_600, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "old"); + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "completed"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + DurableAgentRunOutcome outcome = + DurableAgentStateOutcomeResolver.Resolve(state, "completed", now); + Assert.Equal(DurableAgentRunOutcomeKind.Succeeded, outcome.Kind); + Assert.Equal("authoritative result", outcome.Response?.Text); + Assert.Contains("completed", state.Data.CompletionReceipts!.Keys); + } + + [Fact] + public void AutoFailsWhenProtectedMailboxAndNewestTranscriptExceedBudget() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddMailboxResult(state, "completed", new string('m', 2_000), now.AddMinutes(-5)); + AddExchange(state, "newest", new string('c', 500), now); + + _ = Assert.Throws( + () => DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 1_500, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session"))); + + Assert.Contains("completed", state.Data.TerminalResults!.Keys); + Assert.Contains("completed", state.Data.CompletionReceipts!.Keys); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + } + + [Fact] + public void AutoFailsRatherThanPersistProtectedStateOverSafeThreshold() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "newest", new string('x', 2_000), now); + + DurableAgentStateSizeLimitExceededException exception = + Assert.Throws( + () => DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 500, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session"))); + + Assert.True(exception.StateSizeBytes >= 500 * DurableAgentStateRetention.HighWatermark); + Assert.Equal(500, exception.MaxStateBytes); + Assert.Equal(2, state.Data.ConversationHistory.Count); + } + + [Fact] + public void AutoRemovesToolCallAndResultAtomicallyWithExchange() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + DurableAgentStateRequest request = CreateRequest("tools", ChatRole.User, new string('a', 400), now.AddMinutes(-10)); + DurableAgentStateResponse response = DurableAgentStateResponse.FromResponse( + "tools", + new AgentResponse( + new ChatMessage( + ChatRole.Assistant, + [ + new FunctionCallContent("call", "tool"), + new FunctionResultContent("call", "result"), + ]) + { + CreatedAt = now.AddMinutes(-10), + })); + state.Data.ConversationHistory.Add(request); + state.Data.ConversationHistory.Add(response); + AddExchange(state, "newest", new string('b', 400), now); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "tools"); + Assert.DoesNotContain( + state.Data.ConversationHistory.SelectMany(entry => entry.Messages).SelectMany(message => message.Contents), + content => content is DurableAgentStateFunctionCallContent or DurableAgentStateFunctionResultContent); + } + + [Fact] + public void AutoEvictsToolCallAndResultAcrossDifferentCorrelations() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add( + CreateRequest("call", ChatRole.User, "invoke", now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolCallResponse("call", "shared-call", new string('a', 2_000), now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolResultRequest("result", "shared-call", new string('b', 2_000), now.AddMinutes(-9))); + state.Data.ConversationHistory.Add( + CreateResponse("result", "after tool", now.AddMinutes(-9))); + AddExchange(state, "newest", new string('c', 400), now); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 5_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain( + state.Data.ConversationHistory, + entry => entry.CorrelationId is "call" or "result"); + Assert.False(ContainsToolCallId(state, "shared-call")); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + Assert.True( + DurableAgentStateRetention.GetSerializedSize(state) < + 5_000 * DurableAgentStateRetention.HighWatermark); + } + + [Fact] + public void AutoTreatsInterleavedToolCallsAsOneConnectedComponent() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add( + CreateRequest("calls", ChatRole.User, "invoke", now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolCallResponse( + "calls", + now.AddMinutes(-10), + ("call-a", new string('a', 1_000)), + ("call-b", new string('b', 1_000)))); + state.Data.ConversationHistory.Add( + CreateToolResultRequest("result-a", "call-a", new string('c', 1_000), now.AddMinutes(-9))); + state.Data.ConversationHistory.Add( + CreateResponse("result-a", "after a", now.AddMinutes(-9))); + state.Data.ConversationHistory.Add( + CreateToolResultRequest("result-b", "call-b", new string('d', 1_000), now.AddMinutes(-8))); + state.Data.ConversationHistory.Add( + CreateResponse("result-b", "after b", now.AddMinutes(-8))); + AddExchange(state, "newest", new string('e', 400), now); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 5_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain( + state.Data.ConversationHistory, + entry => entry.CorrelationId is "calls" or "result-a" or "result-b"); + Assert.False(ContainsToolCallId(state, "call-a")); + Assert.False(ContainsToolCallId(state, "call-b")); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + } + + [Fact] + public void AutoProtectsWholeToolComponentWhenResultIsInNewestExchange() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "filler", new string('f', 7_000), now.AddMinutes(-20)); + state.Data.ConversationHistory.Add( + CreateRequest("call", ChatRole.User, "invoke", now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolCallResponse("call", "protected-call", new string('a', 500), now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolResultRequest("newest", "protected-call", new string('b', 500), now)); + state.Data.ConversationHistory.Add( + CreateResponse("newest", "final", now)); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 5_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "filler"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "call"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + Assert.True(ContainsToolCallId(state, "protected-call")); + } + + [Fact] + public void AutoTreatsDuplicateToolIdsAsOneConservativeGroup() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "filler", new string('f', 7_000), now.AddMinutes(-20)); + state.Data.ConversationHistory.Add( + CreateToolCallResponse("first", "duplicate", new string('a', 500), now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolCallResponse("second", "duplicate", new string('b', 500), now.AddMinutes(-5))); + state.Data.ConversationHistory.Add( + CreateToolResultRequest("newest", "duplicate", "result", now)); + state.Data.ConversationHistory.Add( + CreateResponse("newest", "final", now)); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 5_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "filler"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "first"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "second"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "newest"); + } + + [Fact] + public void AutoDoesNotConnectOrphanedToolContentWithDifferentIds() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add( + CreateToolCallResponse("orphan-call", "call-only", new string('a', 5_000), now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolResultRequest("orphan-result", "result-only", new string('b', 500), now.AddMinutes(-5))); + state.Data.ConversationHistory.Add( + CreateResponse("orphan-result", "after orphan", now.AddMinutes(-5))); + AddExchange(state, "newest", new string('c', 400), now); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 4_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "orphan-call"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "orphan-result"); + Assert.True(ContainsToolCallId(state, "result-only")); + } + + [Fact] + public void AutoDoesNotConnectToolContentWithMissingIds() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add( + CreateToolCallResponse("missing-call", string.Empty, new string('a', 5_000), now.AddMinutes(-10))); + state.Data.ConversationHistory.Add( + CreateToolResultRequest("missing-result", string.Empty, new string('b', 500), now.AddMinutes(-5))); + state.Data.ConversationHistory.Add( + CreateResponse("missing-result", "after orphan", now.AddMinutes(-5))); + AddExchange(state, "newest", new string('c', 400), now); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 4_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "missing-call"); + Assert.Contains(state.Data.ConversationHistory, entry => entry.CorrelationId == "missing-result"); + } + + [Fact] + public void SerializedSizeIncludesSessionAndTruncation() + { + DurableAgentState state = CreateRevisedState(); + int emptySize = DurableAgentStateRetention.GetSerializedSize(state); + state.Data.Session = JsonSerializer.SerializeToElement(new { conversationId = new string('c', 100) }); + state.Data.Truncation = new DurableAgentStateTruncation + { + EvictedMessageCount = 2, + FirstEvictedAt = DateTimeOffset.UtcNow, + LastEvictedAt = DateTimeOffset.UtcNow, + }; + + int completeSize = DurableAgentStateRetention.GetSerializedSize(state); + + Assert.True(completeSize > emptySize + 100); + } + + [Fact] + public void AutoPreservesMailboxContinuationBindingTtlAndBookkeepingFloor() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState( + DurableAgentStateHistoryBinding.HistoryProviderOwner, + "external-history.v1"); + AddMailboxResult(state, "completed", new string('r', 2_000), now.AddMinutes(-5)); + state.Data.Session = JsonSerializer.SerializeToElement( + new { continuation = new string('s', 2_000) }); + state.Data.ExpirationTimeUtc = now.AddDays(1).UtcDateTime; + state.Data.IngestedPositions = new Dictionary + { + ["workflow"] = 42, + }; + state.Data.UnknownProperties = new Dictionary + { + ["control"] = JsonSerializer.SerializeToElement(new string('e', 1_000)), + }; + + DurableAgentStateSizeLimitExceededException exception = + Assert.Throws( + () => DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 1_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session"))); + + Assert.Empty(state.Data.ConversationHistory); + Assert.Contains("completed", state.Data.TerminalResults!.Keys); + Assert.Contains("completed", state.Data.CompletionReceipts!.Keys); + Assert.Equal( + "external-history.v1", + DurableAgentHistoryBinding.Parse(state.Data.HistoryBinding)?.ProviderKey); + Assert.NotNull(state.Data.Session); + Assert.NotNull(state.Data.ExpirationTimeUtc); + Assert.Equal(42, state.Data.IngestedPositions?["workflow"]); + Assert.True(exception.StateSizeBytes > exception.MaxStateBytes); + } + + [Fact] + public void AutoPreservesLosslessStructuredResultAndUnavailableReceipt() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + DurableAgentStateOutcomeResolver.AddSuccessfulResult( + state, + "lossless", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "mailbox")), + now.AddMinutes(-5), + structuredValue: JsonSerializer.SerializeToElement( + new { count = 3, label = "retained" })); + DurableAgentStateOutcomeResolver.AddSuccessfulResult( + state, + "unavailable", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "expired")), + now.AddMinutes(-5), + resultExpiresAt: now.AddMinutes(-1)); + Assert.True( + DurableAgentStateOutcomeResolver.MarkExpiredResultUnavailable( + state, + "unavailable", + now)); + AddExchange(state, "old", new string('x', 4_000), now.AddMinutes(-10)); + AddExchange(state, "newest", "newest", now); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 4_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + DurableAgentRunOutcome lossless = + DurableAgentStateOutcomeResolver.Resolve(state, "lossless", now); + Assert.Equal(DurableAgentRunOutcomeKind.Succeeded, lossless.Kind); + Assert.Equal(3, lossless.Value.GetProperty("count").GetInt32()); + Assert.Equal("retained", lossless.Value.GetProperty("label").GetString()); + DurableAgentRunOutcome unavailable = + DurableAgentStateOutcomeResolver.Resolve(state, "unavailable", now); + Assert.Equal( + DurableAgentRunOutcomeKind.CompletedResultUnavailable, + unavailable.Kind); + Assert.Equal( + DurableAgentStateCompletionReceipt.UnavailableResult, + unavailable.Receipt?.ResultState); + Assert.DoesNotContain("unavailable", state.Data.TerminalResults!.Keys); + } + + [Fact] + public void AutoAccountsForMixedTextMediaAndMetadataWhenEvictingTranscript() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + ChatMessage oldMessage = new( + ChatRole.User, + [ + new TextContent(new string('t', 2_000)), + new DataContent( + "data:application/octet-stream;base64," + + Convert.ToBase64String(new byte[4_000]), + mediaType: null), + ]) + { + CreatedAt = now.AddMinutes(-5), + AdditionalProperties = new() + { + ["metadata"] = new string('m', 2_000), + }, + }; + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CorrelationId = "mixed", + CreatedAt = now.AddMinutes(-5), + Messages = [DurableAgentStateMessage.FromChatMessage(oldMessage)], + }); + state.Data.ConversationHistory.Add( + CreateResponse("mixed", "old response", now.AddMinutes(-5))); + AddExchange(state, "newest", "newest", now); + int initialSize = DurableAgentStateRetention.GetSerializedSize(state); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 4_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.True(removed > 0); + Assert.DoesNotContain(state.Data.ConversationHistory, entry => entry.CorrelationId == "mixed"); + Assert.True(DurableAgentStateRetention.GetSerializedSize(state) < initialSize); + } + + [Fact] + public void PublicRetentionModesAreOnlyKeepAllAndAuto() + { + Assert.Equal( + [nameof(DurableAgentHistoryRetentionMode.KeepAll), nameof(DurableAgentHistoryRetentionMode.Auto)], + Enum.GetNames()); + } + + [Fact] + public void AutoRejectsLegacyLayoutBeforeRemovingTerminalEvidence() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = new(); + AddExchange(state, "legacy", new string('x', 2_000), now.AddMinutes(-5)); + AddExchange(state, "newest", "newest", now); + string original = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + DurableAgentStateCorruptionException exception = + Assert.Throws( + () => DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 1_000, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session"))); + + Assert.Contains("schema 2 mailbox", exception.Message, StringComparison.Ordinal); + Assert.Equal( + original, + JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void AutoCanEvictOlderCorrelationlessCompaction() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = now.AddMinutes(-5), + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, new string('a', 1_000))), + ], + }); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = now, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, "newest")), + ], + }); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 1_200, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + Assert.Equal(1, removed); + DurableAgentStateCompaction remaining = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + Assert.Equal("newest", remaining.Messages[0].ToChatMessage().Text); + } + + [Fact] + public void AutoProtectsActualNewestCorrelationlessTranscriptComponent() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "older", new string('o', 4_000), now.AddMinutes(-5)); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = now, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, new string('n', 500))), + ], + }); + + _ = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_500, + now, + NullLogger.Instance, + new AgentSessionId("agent", "session")); + + DurableAgentStateCompaction newest = + Assert.Single(state.Data.ConversationHistory.OfType()); + Assert.Equal(new string('n', 500), newest.Messages[0].ToChatMessage().Text); + Assert.DoesNotContain( + state.Data.ConversationHistory, + entry => entry.CorrelationId == "older"); + } + + private static DurableAgentState CreateLargeState(DateTimeOffset? now = null) + { + DateTimeOffset current = now ?? DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "oldest", new string('a', 500), current.AddMinutes(-10)); + AddExchange(state, "middle", new string('b', 500), current.AddMinutes(-5)); + AddExchange(state, "newest", new string('c', 500), current); + return state; + } + + private static DurableAgentState CreateRevisedState( + string ownerKind = DurableAgentStateHistoryBinding.DurableStateOwner, + string providerKey = DurableAgentHistoryBinding.DurableStateProviderKey) + { + DurableAgentHistoryOwnership ownership = ownerKind switch + { + DurableAgentStateHistoryBinding.DurableStateOwner => + DurableAgentHistoryOwnership.Entity, + DurableAgentStateHistoryBinding.HistoryProviderOwner => + DurableAgentHistoryOwnership.ExternalProvider, + DurableAgentStateHistoryBinding.ModelServiceOwner => + DurableAgentHistoryOwnership.Service, + _ => throw new ArgumentOutOfRangeException(nameof(ownerKind)), + }; + return new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary( + StringComparer.Ordinal), + CompletionReceipts = new Dictionary( + StringComparer.Ordinal), + HistoryBinding = DurableAgentHistoryBinding.ToJson( + DurableAgentHistoryBinding.Create( + ownership, + ownership == DurableAgentHistoryOwnership.Entity + ? null + : providerKey)), + }, + }; + } + + private static void AddMailboxResult( + DurableAgentState state, + string correlationId, + string content, + DateTimeOffset completedAt) + { + DurableAgentStateOutcomeResolver.AddSuccessfulResult( + state, + correlationId, + new AgentResponse(new ChatMessage(ChatRole.Assistant, content)), + completedAt); + } + + private static void AddExchange( + DurableAgentState state, + string correlationId, + string content, + DateTimeOffset createdAt) + { + state.Data.ConversationHistory.Add( + CreateRequest(correlationId, ChatRole.User, content, createdAt)); + state.Data.ConversationHistory.Add( + new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, content) { CreatedAt = createdAt }), + ], + }); + } + + private static DurableAgentStateRequest CreateRequest( + string correlationId, + ChatRole role, + string content, + DateTimeOffset createdAt) + { + return new DurableAgentStateRequest + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(role, content) { CreatedAt = createdAt }), + ], + }; + } + + private static DurableAgentStateResponse CreateResponse( + string correlationId, + string content, + DateTimeOffset createdAt) + { + return new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, content) { CreatedAt = createdAt }), + ], + }; + } + + private static DurableAgentStateResponse CreateToolCallResponse( + string correlationId, + string callId, + string payload, + DateTimeOffset createdAt) + => CreateToolCallResponse(correlationId, createdAt, (callId, payload)); + + private static DurableAgentStateResponse CreateToolCallResponse( + string correlationId, + DateTimeOffset createdAt, + params (string CallId, string Payload)[] calls) + { + List contents = calls + .Select(call => (AIContent)new FunctionCallContent( + call.CallId, + "tool", + new Dictionary { ["payload"] = call.Payload })) + .ToList(); + return new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, contents) { CreatedAt = createdAt }), + ], + }; + } + + private static DurableAgentStateRequest CreateToolResultRequest( + string correlationId, + string callId, + object result, + DateTimeOffset createdAt) + { + return new DurableAgentStateRequest + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(callId, result)]) + { + CreatedAt = createdAt, + }), + ], + }; + } + + private static bool ContainsToolCallId(DurableAgentState state, string callId) + { + return state.Data.ConversationHistory + .SelectMany(entry => entry.Messages) + .SelectMany(message => message.Contents) + .Any(content => content switch + { + DurableAgentStateFunctionCallContent functionCall => functionCall.CallId == callId, + DurableAgentStateFunctionResultContent functionResult => functionResult.CallId == callId, + _ => false, + }); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentTelemetryTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentTelemetryTests.cs new file mode 100644 index 0000000..750225a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentTelemetryTests.cs @@ -0,0 +1,474 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class DurableAgentTelemetryTests +{ + private static readonly string[] s_allowedTagNames = ["agent.name", "outcome", "reason"]; + + [Fact] + public void NormalEvictionRecordsCountsBytesSizesAndBoundedTags() + { + const string AgentName = "metric-normal"; + const string SessionKey = "do-not-export-session"; + const string Content = "do-not-export-content"; + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateLargeState(now, Content); + int initialSize = DurableAgentStateRetention.GetSerializedSize(state); + using RetentionMetricListener listener = new(AgentName); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_500, + now, + NullLogger.Instance, + new AgentSessionId(AgentName, SessionKey)); + + int finalSize = DurableAgentStateRetention.GetSerializedSize(state); + MetricMeasurement operation = listener.Single( + DurableAgentTelemetry.RetentionOperationsInstrumentName); + MetricMeasurement evictedEntries = listener.Single( + DurableAgentTelemetry.EvictedEntriesInstrumentName); + MetricMeasurement evicted = listener.Single( + DurableAgentTelemetry.EvictedMessagesInstrumentName); + MetricMeasurement reclaimed = listener.Single( + DurableAgentTelemetry.ReclaimedBytesInstrumentName); + MetricMeasurement before = listener.Single( + DurableAgentTelemetry.StateSizeBeforeInstrumentName); + MetricMeasurement after = listener.Single( + DurableAgentTelemetry.StateSizeAfterInstrumentName); + + Assert.Equal(DurableAgentTelemetry.TranscriptEvictedOutcome, operation.Tags["outcome"]); + Assert.Equal(DurableAgentTelemetry.TranscriptPressureReason, evicted.Tags["reason"]); + Assert.Equal("{operation}", operation.Unit); + Assert.Equal("{entry}", evictedEntries.Unit); + Assert.Equal("{message}", evicted.Unit); + Assert.Equal("By", reclaimed.Unit); + Assert.Equal("By", before.Unit); + Assert.Equal("By", after.Unit); + Assert.Equal(removed, evicted.Value); + Assert.Equal(removed, evictedEntries.Value); + Assert.Equal(initialSize - finalSize, reclaimed.Value); + Assert.Equal(initialSize, before.Value); + Assert.Equal(finalSize, after.Value); + Assert.All( + listener.Measurements, + measurement => + { + Assert.Equal(AgentName, measurement.Tags["agent.name"]); + Assert.DoesNotContain(SessionKey, measurement.Tags.Values); + Assert.DoesNotContain(Content, measurement.Tags.Values); + Assert.All( + measurement.Tags.Keys, + key => Assert.Contains(key, s_allowedTagNames)); + }); + } + + [Fact] + public void ZeroMessageEntryEvictionRecordsEntryOutcomeAndReclaimedBytes() + { + const string AgentName = "metric-empty-entry"; + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = now.AddMinutes(-5), + ExtensionData = new Dictionary + { + ["padding"] = JsonSerializer.SerializeToElement(new string('x', 2_000)), + }, + }); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = now, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, "newest")), + ], + }); + int initialSize = DurableAgentStateRetention.GetSerializedSize(state); + using RetentionMetricListener listener = new(AgentName); + + int removedMessages = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_000, + now, + NullLogger.Instance, + new AgentSessionId(AgentName, "session")); + + int finalSize = DurableAgentStateRetention.GetSerializedSize(state); + MetricMeasurement operation = listener.Single( + DurableAgentTelemetry.RetentionOperationsInstrumentName); + MetricMeasurement evictedEntries = listener.Single( + DurableAgentTelemetry.EvictedEntriesInstrumentName); + MetricMeasurement reclaimed = listener.Single( + DurableAgentTelemetry.ReclaimedBytesInstrumentName); + + Assert.Equal(0, removedMessages); + Assert.Equal(DurableAgentTelemetry.TranscriptEvictedOutcome, operation.Tags["outcome"]); + Assert.Equal(1, evictedEntries.Value); + Assert.Equal(DurableAgentTelemetry.TranscriptPressureReason, evictedEntries.Tags["reason"]); + Assert.Empty(listener.Find(DurableAgentTelemetry.EvictedMessagesInstrumentName)); + Assert.Equal(initialSize - finalSize, reclaimed.Value); + Assert.True(reclaimed.Value > 0); + } + + [Fact] + public void ZeroMessageEvictionDoesNotCreateInvalidTruncationEvidence() + { + const string AgentName = "metric-truncation-offset"; + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = now.AddMinutes(-5), + }); + state.Data.ConversationHistory.Add( + new DurableAgentStateCompaction + { + CreatedAt = now, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, new string('x', 2_000))), + ], + }); + int initialSize = DurableAgentStateRetention.GetSerializedSize(state); + using RetentionMetricListener listener = new(AgentName); + + _ = Assert.Throws( + () => DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + initialSize, + now, + NullLogger.Instance, + new AgentSessionId(AgentName, "session"))); + + int finalSize = DurableAgentStateRetention.GetSerializedSize(state); + MetricMeasurement operation = listener.Single( + DurableAgentTelemetry.RetentionOperationsInstrumentName); + MetricMeasurement evictedEntries = listener.Single( + DurableAgentTelemetry.EvictedEntriesInstrumentName); + MetricMeasurement reclaimed = listener.Single( + DurableAgentTelemetry.ReclaimedBytesInstrumentName); + + Assert.True(finalSize < initialSize); + Assert.Equal( + DurableAgentTelemetry.ProtectedStateCapacityFailureOutcome, + operation.Tags["outcome"]); + Assert.Equal(1, evictedEntries.Value); + Assert.Equal(DurableAgentTelemetry.TranscriptPressureReason, evictedEntries.Tags["reason"]); + Assert.Empty(listener.Find(DurableAgentTelemetry.EvictedMessagesInstrumentName)); + Assert.Equal(initialSize - finalSize, reclaimed.Value); + Assert.Null(state.Data.Truncation); + } + + [Fact] + public void ProtectedStateFailureRecordsFailedOutcomeAndSizes() + { + const string AgentName = "metric-failure"; + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "newest", new string('x', 2_000), now); + int initialSize = DurableAgentStateRetention.GetSerializedSize(state); + using RetentionMetricListener listener = new(AgentName); + + _ = Assert.Throws( + () => DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 500, + now, + NullLogger.Instance, + new AgentSessionId(AgentName, "session"))); + + MetricMeasurement operation = listener.Single( + DurableAgentTelemetry.RetentionOperationsInstrumentName); + MetricMeasurement before = listener.Single( + DurableAgentTelemetry.StateSizeBeforeInstrumentName); + MetricMeasurement after = listener.Single( + DurableAgentTelemetry.StateSizeAfterInstrumentName); + + Assert.Equal( + DurableAgentTelemetry.ProtectedStateCapacityFailureOutcome, + operation.Tags["outcome"]); + Assert.Equal(initialSize, before.Value); + Assert.Equal(DurableAgentStateRetention.GetSerializedSize(state), after.Value); + Assert.Empty(listener.Find(DurableAgentTelemetry.EvictedMessagesInstrumentName)); + Assert.Empty(listener.Find(DurableAgentTelemetry.EvictedEntriesInstrumentName)); + Assert.Empty(listener.Find(DurableAgentTelemetry.ReclaimedBytesInstrumentName)); + } + + [Fact] + public void BelowHighWatermarkRecordsOnlyNoActionOperation() + { + const string AgentName = "metric-no-action"; + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "newest", "small", now); + using RetentionMetricListener listener = new(AgentName); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 100_000, + now, + NullLogger.Instance, + new AgentSessionId(AgentName, "session")); + + Assert.Equal(0, removed); + MetricMeasurement operation = listener.Single( + DurableAgentTelemetry.RetentionOperationsInstrumentName); + Assert.Equal(DurableAgentTelemetry.NoActionOutcome, operation.Tags["outcome"]); + Assert.Single(listener.Measurements); + } + + [Fact] + public void KeepAllDoesNotEmitRetentionMetrics() + { + const string AgentName = "metric-keep-all"; + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateLargeState(now, "large"); + using RetentionMetricListener listener = new(AgentName); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.KeepAll, + 500, + now, + NullLogger.Instance, + new AgentSessionId(AgentName, "session")); + + Assert.Equal(0, removed); + Assert.Empty(listener.Measurements); + } + + [Fact] + public void ConcurrentRetentionCallsRecordIndependently() + { + const string AgentName = "metric-concurrent"; + const int AttemptCount = 32; + DateTimeOffset now = DateTimeOffset.UtcNow; + using RetentionMetricListener listener = new(AgentName); + + Parallel.For( + 0, + AttemptCount, + _ => + { + DurableAgentState state = CreateLargeState(now, "payload"); + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_500, + now, + NullLogger.Instance, + new AgentSessionId(AgentName, "session")); + Assert.True(removed > 0); + }); + + Assert.Equal( + AttemptCount, + listener.Find(DurableAgentTelemetry.RetentionOperationsInstrumentName).Count); + Assert.Equal( + AttemptCount, + listener.Find(DurableAgentTelemetry.StateSizeBeforeInstrumentName).Count); + Assert.Equal( + AttemptCount, + listener.Find(DurableAgentTelemetry.StateSizeAfterInstrumentName).Count); + } + + [Fact] + public void ListenerAbsenceDoesNotChangeRetention() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateLargeState(now, "payload"); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_500, + now, + NullLogger.Instance, + new AgentSessionId("metric-no-listener", "session")); + + Assert.True(removed > 0); + Assert.DoesNotContain( + state.Data.ConversationHistory, + entry => entry.CorrelationId == "oldest"); + Assert.Contains( + state.Data.ConversationHistory, + entry => entry.CorrelationId == "newest"); + } + + [Fact] + public void ThrowingListenerCannotAffectRetention() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DurableAgentState state = CreateLargeState(now, "payload"); + using MeterListener listener = new(); + listener.InstrumentPublished = static (instrument, meterListener) => + { + if (instrument.Meter.Name == DurableAgentTelemetry.MeterName) + { + meterListener.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback( + static (_, _, _, _) => throw new InvalidOperationException("listener failure")); + listener.Start(); + + int removed = DurableAgentStateRetention.Enforce( + state, + DurableAgentHistoryRetentionMode.Auto, + 2_500, + now, + NullLogger.Instance, + new AgentSessionId("metric-throwing-listener", "session")); + + Assert.True(removed > 0); + Assert.Contains( + state.Data.ConversationHistory, + entry => entry.CorrelationId == "newest"); + } + + private static DurableAgentState CreateLargeState( + DateTimeOffset now, + string content) + { + DurableAgentState state = CreateRevisedState(); + AddExchange(state, "oldest", new string('a', 500) + content, now.AddMinutes(-10)); + AddExchange(state, "middle", new string('b', 500), now.AddMinutes(-5)); + AddExchange(state, "newest", new string('c', 500), now); + return state; + } + + private static DurableAgentState CreateRevisedState() + { + return new DurableAgentState + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary( + StringComparer.Ordinal), + CompletionReceipts = new Dictionary( + StringComparer.Ordinal), + HistoryBinding = DurableAgentHistoryBinding.ToJson( + DurableAgentHistoryBinding.Create( + DurableAgentHistoryOwnership.Entity, + configuredProviderKey: null)), + }, + }; + } + + private static void AddExchange( + DurableAgentState state, + string correlationId, + string content, + DateTimeOffset createdAt) + { + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.User, content) { CreatedAt = createdAt }), + ], + }); + state.Data.ConversationHistory.Add( + new DurableAgentStateResponse + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = + [ + DurableAgentStateMessage.FromChatMessage( + new ChatMessage(ChatRole.Assistant, content) { CreatedAt = createdAt }), + ], + }); + } + + private sealed record MetricMeasurement( + string InstrumentName, + string? Unit, + long Value, + IReadOnlyDictionary Tags); + + private sealed class RetentionMetricListener : IDisposable + { + private readonly string _agentName; + private readonly ConcurrentQueue _measurements = new(); + private readonly MeterListener _listener = new(); + + public RetentionMetricListener(string agentName) + { + this._agentName = agentName; + this._listener.InstrumentPublished = static (instrument, listener) => + { + if (instrument.Meter.Name == DurableAgentTelemetry.MeterName) + { + listener.EnableMeasurementEvents(instrument); + } + }; + this._listener.SetMeasurementEventCallback(this.Record); + this._listener.Start(); + } + + public IReadOnlyList Measurements => [.. this._measurements]; + + public List Find(string instrumentName) => + this.Measurements + .Where(measurement => measurement.InstrumentName == instrumentName) + .ToList(); + + public MetricMeasurement Single(string instrumentName) => + Assert.Single(this.Find(instrumentName)); + + public void Dispose() => this._listener.Dispose(); + + private void Record( + Instrument instrument, + long measurement, + ReadOnlySpan> tags, + object? state) + { + Dictionary copiedTags = new(StringComparer.Ordinal); + foreach (KeyValuePair tag in tags) + { + copiedTags[tag.Key] = tag.Value; + } + + if (copiedTags.TryGetValue( + DurableAgentTelemetry.AgentNameTagName, + out object? agentName) && + string.Equals(agentName as string, this._agentName, StringComparison.Ordinal)) + { + this._measurements.Enqueue( + new MetricMeasurement( + instrument.Name, + instrument.Unit, + measurement, + copiedTags)); + } + } + } +} 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..01eb620 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableChatHistoryProviderTests.cs @@ -0,0 +1,449 @@ +// 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 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); + } + + [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"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index c548159..e10bb5e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -10,4 +10,22 @@ + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs index 2fda117..6e1badc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.Agents.AI.DurableTask.State; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; @@ -42,6 +44,29 @@ public void ErrorContentSerializationDeserialization() Assert.Equal(errorContent.ErrorCode, convertedErrorContent.ErrorCode); } + [Fact] + public void ErrorContentPreservesNonStringPythonDetails() + { + const string Json = """ + { + "$type": "error", + "message": "failed", + "details": { + "retryable": true + } + } + """; + DurableAgentStateContent stored = Assert.IsType( + JsonSerializer.Deserialize(Json, s_stateContentTypeInfo)); + + ErrorContent restored = Assert.IsType(stored.ToAIContent()); + string roundTrip = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + + using JsonDocument details = JsonDocument.Parse(restored.Details!); + Assert.True(details.RootElement.GetProperty("retryable").GetBoolean()); + Assert.Contains("\"details\":{\"retryable\":true}", roundTrip, StringComparison.Ordinal); + } + [Fact] public void TextContentSerializationDeserialization() { @@ -299,26 +324,464 @@ public void UsageContentSerializationDeserialization() } [Fact] - public void UnknownContentSerializationDeserialization() + public void UsageAdditionalCountsRoundTripThroughExtensionData() { - // Arrange - TextContent originalContent = new("Some unknown content"); + UsageDetails usageDetails = new() + { + InputTokenCount = 10, + AdditionalCounts = new AdditionalPropertiesDictionary + { + ["providerCount"] = 7, + }, + }; - DurableAgentStateContent durableContent = DurableAgentStateUnknownContent.FromUnknownContent(originalContent); + DurableAgentStateUsage stored = Assert.IsType( + DurableAgentStateUsage.FromUsage(usageDetails)); + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!); + DurableAgentStateUsage restored = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!)); + UsageDetails converted = restored.ToUsageDetails(); + + Assert.Contains("\"extensionData\":{\"providerCount\":7}", json, StringComparison.Ordinal); + Assert.Equal(7, converted.AdditionalCounts?["providerCount"]); + } - // Act - string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + [Fact] + public void UsageProjectionIgnoresMalformedExtensionsAndPreservesTheirJson() + { + const string Json = """ + { + "inputTokenCount": 10, + "extensionData": { + "providerCount": 7, + "futureString": "seven", + "futureObject": { "count": 8 }, + "futureArray": [9], + "fractional": 1.5, + "tooLarge": 9223372036854775808 + }, + "futureTopLevelCount": 11, + "futureTopLevelObject": { "count": 12 } + } + """; + JsonTypeInfo usageTypeInfo = + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!; + DurableAgentStateUsage stored = Assert.IsType( + JsonSerializer.Deserialize(Json, usageTypeInfo)); + + UsageDetails usage = stored.ToUsageDetails(); + string roundTrip = JsonSerializer.Serialize(stored, usageTypeInfo); + + Assert.Equal(10, usage.InputTokenCount); + Assert.Equal(7, usage.AdditionalCounts?["providerCount"]); + Assert.Equal(11, usage.AdditionalCounts?["futureTopLevelCount"]); + Assert.DoesNotContain("futureString", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureObject", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureArray", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("fractional", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("tooLarge", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureTopLevelObject", usage.AdditionalCounts?.Keys ?? []); + Assert.Contains("\"futureString\":\"seven\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureObject\":{\"count\":8}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureArray\":[9]", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureTopLevelObject\":{\"count\":12}", roundTrip, StringComparison.Ordinal); + } - DurableAgentStateContent? convertedJsonContent = - (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + [Theory] + [InlineData("\"ten\"")] + [InlineData("{}")] + [InlineData("1.5")] + [InlineData("9223372036854775808")] + public void UsageDeserializationRejectsMalformedKnownNumericFields(string invalidValue) + { + string json = $$""" + { + "inputTokenCount": {{invalidValue}} + } + """; + JsonTypeInfo usageTypeInfo = + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!; - // Assert - Assert.NotNull(convertedJsonContent); + Assert.Throws(() => JsonSerializer.Deserialize(json, usageTypeInfo)); + } - AIContent convertedContent = convertedJsonContent.ToAIContent(); + [Fact] + public void KnownContentDiscriminatorDoesNotUseUnknownEnvelope() + { + TextContent originalContent = new("Some unknown content"); + DurableAgentStateContent durableContent = + DurableAgentStateContent.FromAIContent(originalContent); + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + DurableAgentStateTextContent convertedState = + Assert.IsType(convertedJsonContent); + AIContent convertedContent = convertedState.ToAIContent(); TextContent convertedTextContent = Assert.IsType(convertedContent); Assert.Equal(originalContent.Text, convertedTextContent.Text); + Assert.Contains("\"$type\":\"text\"", jsonContent, StringComparison.Ordinal); + Assert.DoesNotContain("$microsoftAgentFrameworkDurableTask", jsonContent, StringComparison.Ordinal); + Assert.DoesNotContain("$runtimeType", jsonContent, StringComparison.Ordinal); + } + + [Fact] + public void UnknownContentWithUnrecognizedPayloadFallsBackWithoutDataLoss() + { + DurableAgentStateUnknownContent stored = new() + { + Content = JsonSerializer.SerializeToElement( + new { type = "future_content", value = 42 }), + }; + + AIContent restored = stored.ToAIContent(); + + JsonElement content = + Assert.IsType(restored.AdditionalProperties?["content"]); + Assert.Equal("future_content", content.GetProperty("type").GetString()); + Assert.Equal(42, content.GetProperty("value").GetInt32()); + } + + [Fact] + public void PythonShapedOpaqueUnknownContentWithRuntimeTypeRoundTripsUnchanged() + { + using JsonDocument document = JsonDocument.Parse( + """ + { + "$runtimeType": "producer-owned-user-value", + "type": "future_python_content", + "annotations": [{ "kind": "citation", "value": "python-ref" }], + "additionalProperties": { "producer": "python" }, + "future": { "nested": [1, 2, 3] } + } + """); + JsonElement original = document.RootElement.Clone(); + DurableAgentStateUnknownContent stored = new() { Content = original }; + + AIContent restored = Assert.IsType(stored.ToAIContent()); + DurableAgentStateUnknownContent roundTripped = Assert.IsType( + DurableAgentStateContent.FromAIContent(restored)); + + Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); + Assert.Equal( + "producer-owned-user-value", + roundTripped.Content.GetProperty("$runtimeType").GetString()); + Assert.Equal( + 3, + roundTripped.Content.GetProperty("future").GetProperty("nested").GetArrayLength()); + } + + [Fact] + public void FutureDurableEnvelopeFieldsRemainOpaque() + { + using JsonDocument document = JsonDocument.Parse( + """ + { + "$microsoftAgentFrameworkDurableTask": { + "kind": "unknownAIContent", + "version": 1, + "futureMetadata": { "preserve": true } + } + } + """); + JsonElement original = document.RootElement.Clone(); + DurableAgentStateUnknownContent stored = new() { Content = original }; + + AIContent restored = Assert.IsType(stored.ToAIContent()); + DurableAgentStateUnknownContent roundTripped = Assert.IsType( + DurableAgentStateContent.FromAIContent(restored)); + + Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); + } + + [Fact] + public void UnregisteredAIContentSubtypePersistsCommonContractAsUnknown() + { + FutureContent original = new() + { + FutureValue = "not part of the common contract", + RawRepresentation = new { kind = "future", value = 42 }, + AdditionalProperties = new() + { + ["providerFlag"] = true, + }, + Annotations = + [ + new AIAnnotation + { + AdditionalProperties = new() + { + ["citation"] = "ref-1", + }, + }, + ], + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + DurableAgentStateContent roundTripped = Assert.IsType( + JsonSerializer.Deserialize(json, s_stateContentTypeInfo)); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement persistedContent = document.RootElement.GetProperty("content"); + JsonElement envelope = + persistedContent.GetProperty("$microsoftAgentFrameworkDurableTask"); + Assert.Equal("unknownAIContent", envelope.GetProperty("kind").GetString()); + Assert.Equal(1, envelope.GetProperty("version").GetInt32()); + Assert.False(persistedContent.TryGetProperty("$runtimeType", out _)); + Assert.DoesNotContain(typeof(FutureContent).FullName!, json, StringComparison.Ordinal); + Assert.False(envelope.TryGetProperty(nameof(FutureContent.FutureValue), out _)); + + AIContent restored = Assert.IsType(roundTripped.ToAIContent()); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["providerFlag"]).GetBoolean()); + Assert.Equal( + "ref-1", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["citation"]).GetString()); + JsonElement rawRepresentation = Assert.IsType(restored.RawRepresentation); + Assert.Equal("future", rawRepresentation.GetProperty("kind").GetString()); + Assert.Equal(42, rawRepresentation.GetProperty("value").GetInt32()); + } + + [Fact] + public void UnknownContentOmitsUnsafeValuesAndPreservesSafeMetadata() + { + CyclicPayload cyclicPayload = new(); + cyclicPayload.Self = cyclicPayload; + JsonElement disposedElement; + using (JsonDocument disposedDocument = JsonDocument.Parse("""{"value":"disposed-secret"}""")) + { + disposedElement = disposedDocument.RootElement; + } + + using MemoryStream stream = new([1, 2, 3]); + CollectingLogger logger = new(); + FutureContent original = new() + { + RawRepresentation = new ThrowingGetterPayload(), + AdditionalProperties = new() + { + ["safeString"] = "kept", + ["safeObject"] = new { value = 42 }, + ["cyclic"] = cyclicPayload, + ["delegate"] = () => { }, + ["stream"] = stream, + ["disposedJson"] = disposedElement, + ["invalidNumber"] = double.NaN, + ["customConverter"] = new ThrowingConverterPayload(), + }, + Annotations = + [ + new AIAnnotation + { + RawRepresentation = disposedElement, + AdditionalProperties = new() + { + ["safeAnnotation"] = "annotation-kept", + ["badAnnotation"] = new ThrowingConverterPayload(), + }, + }, + ], + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original, logger)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + Messages = + [ + new DurableAgentStateMessage + { + Role = "assistant", + Contents = [stored], + }, + ], + }); + Exception? finalSerializationException = Record.Exception( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement envelope = document.RootElement.GetProperty("content") + .GetProperty("$microsoftAgentFrameworkDurableTask"); + JsonElement additionalProperties = envelope.GetProperty("additionalProperties"); + Assert.Equal("kept", additionalProperties.GetProperty("safeString").GetString()); + Assert.Equal(42, additionalProperties.GetProperty("safeObject").GetProperty("value").GetInt32()); + Assert.False(additionalProperties.TryGetProperty("cyclic", out _)); + Assert.False(additionalProperties.TryGetProperty("delegate", out _)); + Assert.False(additionalProperties.TryGetProperty("stream", out _)); + Assert.False(additionalProperties.TryGetProperty("disposedJson", out _)); + Assert.False(additionalProperties.TryGetProperty("invalidNumber", out _)); + Assert.False(additionalProperties.TryGetProperty("customConverter", out _)); + Assert.True(envelope.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + Assert.Equal(6, envelope.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + + JsonElement annotation = envelope.GetProperty("annotations")[0]; + Assert.Equal( + "annotation-kept", + annotation.GetProperty("additionalProperties").GetProperty("safeAnnotation").GetString()); + Assert.False( + annotation.GetProperty("additionalProperties").TryGetProperty("badAnnotation", out _)); + Assert.Equal( + 1, + annotation.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + Assert.True( + annotation.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + + AIContent restored = Assert.IsType(stored.ToAIContent()); + Assert.Equal( + "kept", + Assert.IsType(restored.AdditionalProperties?["safeString"]).GetString()); + Assert.Equal( + "annotation-kept", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["safeAnnotation"]).GetString()); + + Assert.Null(finalSerializationException); + Assert.True(logger.WarningCount >= 8); + Assert.All(logger.Exceptions, exception => Assert.Null(exception)); + Assert.All( + logger.Messages, + message => + { + Assert.DoesNotContain("disposed-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("converter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("safeString", message, StringComparison.Ordinal); + }); + } + + [Fact] + public void UnknownSubtypePropertyGetterIsNeverInvoked() + { + ThrowingFutureContent.GetterInvocationCount = 0; + ThrowingFutureContent original = new() + { + AdditionalProperties = new() + { + ["safe"] = true, + }, + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + AIContent restored = stored.ToAIContent(); + + Assert.Equal(0, ThrowingFutureContent.GetterInvocationCount); + Assert.IsType(restored); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["safe"]).GetBoolean()); + Assert.DoesNotContain("$runtimeType", json, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", json, StringComparison.Ordinal); + } + + [Fact] + public void UnknownContentDoesNotSwallowCancellation() + { + FutureContent original = new() + { + RawRepresentation = new CancelingGetterPayload(), + }; + + Assert.ThrowsAny( + () => DurableAgentStateContent.FromAIContent(original)); + } + + private sealed class FutureContent : AIContent + { + public string? FutureValue { get; init; } + } + + private sealed class CyclicPayload + { + public CyclicPayload? Self { get; set; } + } + + private sealed class ThrowingFutureContent : AIContent + { + public static int GetterInvocationCount { get; set; } + + public string Dangerous + { + get + { + GetterInvocationCount++; + throw new InvalidOperationException("getter-secret"); + } + } + } + + private sealed class ThrowingGetterPayload + { + public string Dangerous => throw new InvalidOperationException("getter-secret"); + } + + private sealed class CancelingGetterPayload + { + public string Dangerous => throw new OperationCanceledException(); + } + + [JsonConverter(typeof(ThrowingConverterPayloadConverter))] + public sealed class ThrowingConverterPayload; + + public sealed class ThrowingConverterPayloadConverter : JsonConverter + { + public override ThrowingConverterPayload? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write( + Utf8JsonWriter writer, + ThrowingConverterPayload value, + JsonSerializerOptions options) + { + throw new FormatException("converter-secret"); + } + } + + private sealed class CollectingLogger : ILogger + { + public int WarningCount { get; private set; } + + public List Messages { get; } = []; + + public List Exceptions { get; } = []; + + 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) + { + if (logLevel == LogLevel.Warning) + { + this.WarningCount++; + this.Messages.Add(formatter(state, exception)); + this.Exceptions.Add(exception); + } + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs index ea117f9..62c9493 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs @@ -110,5 +110,45 @@ public void PreviouslyPersistedArgumentsAreStillReadable() Assert.Equal(3, Assert.IsType(result.Arguments["days"]).GetInt32()); } + [Fact] + public void StringArgumentsRoundTripVerbatimWithoutParsing() + { + const string Json = + """{"$type":"functionCall","arguments":" { \"partial\": ","callId":"call-7","name":"incomplete"}"""; + + DurableAgentStateContent? deserialized = + (DurableAgentStateContent?)JsonSerializer.Deserialize(Json, s_stateContentTypeInfo); + DurableAgentStateFunctionCallContent durable = + Assert.IsType(deserialized); + string roundTrip = JsonSerializer.Serialize(durable, s_stateContentTypeInfo); + using JsonDocument roundTripDocument = JsonDocument.Parse(roundTrip); + FunctionCallContent runtime = Assert.IsType(durable.ToAIContent()); + + Assert.Equal(" { \"partial\": ", durable.Arguments.GetString()); + Assert.Equal(" { \"partial\": ", runtime.RawRepresentation); + Assert.Equal( + " { \"partial\": ", + roundTripDocument.RootElement.GetProperty("arguments").GetString()); + } + + [Fact] + public void ProductionMappingDoesNotEmitV2StringArguments() + { + FunctionCallContent runtime = new("call-8", "future") + { + RawRepresentation = "verbatim", + }; + + DurableAgentStateFunctionCallContent legacy = + Assert.IsType( + DurableAgentStateContent.FromAIContent(runtime)); + DurableAgentStateFunctionCallContent revised = + Assert.IsType( + DurableAgentStateContent.FromAIContentV2(runtime)); + + Assert.Equal(JsonValueKind.Undefined, legacy.Arguments.ValueKind); + Assert.Equal("verbatim", revised.Arguments.GetString()); + } + private sealed record Location(string City, string State); } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs new file mode 100644 index 0000000..c5454fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs @@ -0,0 +1,1571 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateMailboxTests +{ + [Fact] + public void VersionedEnvelopeCasesMatchDotNetReaders() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "versioned-envelope-cases.json")); + using JsonDocument document = JsonDocument.Parse(json); + int caseCount = 0; + int schemaOnlyLegacyCases = 0; + + foreach (JsonElement group in document.RootElement.EnumerateArray()) + { + foreach (JsonElement test in group.GetProperty("tests").EnumerateArray()) + { + string stateJson = test.GetProperty("data").GetRawText(); + bool valid = test.GetProperty("valid").GetBoolean(); + if (valid) + { + JsonElement data = test.GetProperty("data"); + if (IsSchemaOnlyLegacyEntryCase(data)) + { + // Historical schema snapshots allowed an undiscriminated generic entry. + // The existing .NET model has always required a typed entry discriminator; + // this implementation must not invent one while round-tripping old data. + schemaOnlyLegacyCases++; + } + else + { + DurableAgentState state = Deserialize(stateJson); + _ = Serialize(state); + } + } + else + { + Assert.ThrowsAny(() => Deserialize(stateJson)); + } + + caseCount++; + } + } + + Assert.Equal(44, caseCount); + Assert.Equal(3, schemaOnlyLegacyCases); + } + + [Theory] + [InlineData("""{"role":"developer","contents":[]}""")] + [InlineData("""{"role":"assistant","contents":[{"$type":"functionCall","callId":"c","name":"f","arguments":"verbatim"}]}""")] + [InlineData("""{"role":"assistant","contents":[{"$type":"uri","uri":"https://example.test/media"}]}""")] + public void LegacySnapshotsRejectV2OnlyMessageShapes(string messageJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{{messageJson}}] + }] + } + } + """; + + Assert.Throws(() => Deserialize(json)); + } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + [InlineData("2.0.0")] + public void ProductionWriterEnforcesVersionedRequestAndResponseShapes(string schemaVersion) + { + string[] messageShapes = + [ + """{"role":"developer","contents":[]}""", + """{"role":"assistant","contents":[{"$type":"functionCall","callId":"c","name":"f","arguments":"verbatim"}]}""", + """{"role":"assistant","contents":[{"$type":"uri","uri":"https://example.test/media"}]}""", + ]; + foreach (string messageJson in messageShapes) + { + DurableAgentStateMessage message = JsonSerializer.Deserialize( + messageJson, DurableAgentStateJsonContext.Default.DurableAgentStateMessage)!; + foreach (bool response in new[] { false, true }) + { + bool revised = schemaVersion == DurableAgentState.RevisedSchemaVersion; + DurableAgentState state = new() + { + SchemaVersion = schemaVersion, + MailboxWritesAuthorized = revised, + Data = new() + { + ConversationHistory = + [ + response + ? new DurableAgentStateResponse { Messages = [message] } + : new DurableAgentStateRequest { Messages = [message] }, + ], + TerminalResults = revised ? new Dictionary() : null, + CompletionReceipts = revised ? new Dictionary() : null, + }, + }; + + if (revised) + { + string json = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + DurableAgentState restored = JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)!; + JsonElement roundTrip = JsonSerializer.SerializeToElement( + Assert.Single(Assert.Single(restored.Data.ConversationHistory).Messages), + DurableAgentStateJsonContext.Default.DurableAgentStateMessage); + using JsonDocument expected = JsonDocument.Parse(messageJson); + Assert.True(JsonElement.DeepEquals(expected.RootElement, roundTrip)); + } + else + { + Assert.Throws(() => + JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState)); + Assert.Null(state.Data.CompletionReceipts); + } + } + } + } + + [Theory] + [InlineData("null")] + [InlineData("[null]")] + public void LegacySnapshotsRejectMalformedConversationHistory(string historyJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": {{historyJson}} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("""{"$type":"request","messages":null}""")] + [InlineData("""{"$type":"request","messages":[null]}""")] + [InlineData("""{"$type":"request","messages":[{"role":null}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":null}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[null]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"text","text":null}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"functionCall","callId":null,"name":"f"}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"uri","uri":null,"mediaType":"text/plain"}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"usage","usage":null}]}]}""")] + public void LegacySnapshotsRejectMalformedEntryShapes(string entryJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{{entryJson}}] + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void LegacyStateRoundTripsWithoutRevisedFields() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [] + } + } + """; + + DurableAgentState state = Deserialize(Json); + string roundTrip = Serialize(state); + + Assert.DoesNotContain("\"terminalResults\"", roundTrip, StringComparison.Ordinal); + Assert.DoesNotContain("\"completionReceipts\"", roundTrip, StringComparison.Ordinal); + Assert.DoesNotContain("\"historyBinding\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void ProductionReaderSupportsRevisedStateButPassiveDtosDoNotActivateNewWrites() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + DurableAgentState state = Deserialize(Json); + + DurableAgentState hydrated = Assert.IsType( + JsonSerializer.Deserialize(Json, DurableAgentStateJsonContext.Default.DurableAgentState)); + Assert.Equal(DurableAgentState.RevisedSchemaVersion, hydrated.SchemaVersion); + Assert.Contains("\"schemaVersion\":\"2.0.0\"", + JsonSerializer.Serialize(hydrated, DurableAgentStateJsonContext.Default.DurableAgentState), + StringComparison.Ordinal); + Assert.Throws( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void RevisedFixtureRoundTripsTypedMailboxAndFutureFields() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")); + + DurableAgentState state = Deserialize(json); + string roundTrip = Serialize(state); + DurableAgentStateTerminalResult result = Assert.IsType( + state.Data.TerminalResults?["corr-2"]); + DurableAgentStateCompletionReceipt unavailable = Assert.IsType( + state.Data.CompletionReceipts?["corr-expired"]); + + Assert.Equal(DurableAgentState.RevisedSchemaVersion, state.SchemaVersion); + Assert.Equal( + "contoso.support-history.v1", + state.Data.HistoryBinding.GetProperty("providerKey").GetString()); + Assert.Equal("response-id-2", result.Response?.ResponseId); + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, unavailable.ResultState); + Assert.Contains("\"futureResponseField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureReceiptField\":7", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureBindingField\":\"preserve\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureRootField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("terminalResults")] + [InlineData("completionReceipts")] + public void RevisedStateRequiresCompleteLayout(string missingProperty) + { + Dictionary data = new() + { + ["conversationHistory"] = Array.Empty(), + ["terminalResults"] = new Dictionary(), + ["completionReceipts"] = new Dictionary(), + ["historyBinding"] = new + { + version = 1, + ownerKind = "durableState", + providerKey = "durable-state.v1", + }, + }; + _ = data.Remove(missingProperty); + string json = JsonSerializer.Serialize(new + { + schemaVersion = DurableAgentState.RevisedSchemaVersion, + data, + }); + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void RevisedStateAllowsOmittedHistoryBinding() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal(JsonValueKind.Undefined, state.Data.HistoryBinding.ValueKind); + } + + [Theory] + [InlineData("request", "")] + [InlineData("response", " ")] + [InlineData("errorResponse", "id\u0001")] + public void RevisedTranscriptRejectsInvalidPresentCorrelation(string entryType, string correlationId) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "{{entryType}}", + "correlationId": {{JsonSerializer.Serialize(correlationId)}} + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void RevisedTranscriptAllowsMissingCorrelationButCompactionForbidsIt() + { + const string MissingCorrelation = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ "$type": "request" }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + const string CompactionCorrelation = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "compaction", + "correlationId": "not-allowed" + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.IsType( + Assert.Single(Deserialize(MissingCorrelation).Data.ConversationHistory)); + Assert.Throws(() => Deserialize(CompactionCorrelation)); + } + + [Fact] + public void TranscriptEntryPreservesAbsentCreatedAt() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ "$type": "request" }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement entry = document.RootElement.GetProperty("data").GetProperty("conversationHistory")[0]; + + Assert.False(entry.TryGetProperty("createdAt", out _)); + } + + [Fact] + public void EntryPreservesFieldsOwnedByAnotherVariantAsUnknown() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "usage": { + "extensionData": null + } + }, { + "$type": "response", + "responseSchema": "opaque" + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"usage\":{\"extensionData\":null}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"responseSchema\":\"opaque\"", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("conversationHistory")] + [InlineData("terminalResults.messages")] + public void RevisedStateRejectsNullRequiredCollections(string collection) + { + string json = collection == "conversationHistory" + ? """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": null, + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """ + : """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "correlation": { + "correlationId": "correlation", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": null } + } + }, + "completionReceipts": { + "correlation": { + "correlationId": "correlation", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void TerminalMessageMayOmitContentsButCannotUseNullEntries() + { + const string MetadataOnlyJson = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [{ "role": "assistant" }] } + } + }, + "completionReceipts": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + const string NullMessageJson = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [null] } + } + }, + "completionReceipts": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + AgentResponse response = Assert.IsType( + Deserialize(MetadataOnlyJson).Data.TerminalResults?["metadata"].Response).ToResponse(); + Assert.Empty(Assert.Single(response.Messages).Contents); + Assert.Throws(() => Deserialize(NullMessageJson)); + } + + [Fact] + public void UnknownMailboxDiscriminatorIsRejected() + { + string json = CreateRevisedJson( + resultOutcome: "futureOutcome", + receiptOutcome: "futureOutcome", + resultState: DurableAgentStateCompletionReceipt.AvailableResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void DuplicateCompletionCorrelationIsRejected() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "duplicate": { + "correlationId": "duplicate", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-10T05:00:01+00:00" + }, + "duplicate": { + "correlationId": "duplicate", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-10T05:00:01+00:00" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Theory] + [InlineData("available", false)] + [InlineData("unavailable", true)] + public void ResultAndReceiptAvailabilityMustBeConsistent(string resultState, bool includeResult) + { + string json = CreateRevisedJson( + resultOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + receiptOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + resultState, + includeResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void ResultAndReceiptMetadataMustMatch() + { + string json = CreateRevisedJson( + resultOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + receiptOutcome: DurableAgentStateCompletionReceipt.FailedOutcome, + resultState: DurableAgentStateCompletionReceipt.AvailableResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void FailedTerminalResultWithMatchingReceiptIsValid() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "failed": { + "correlationId": "failed", + "outcome": "failed", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { + "messages": [{ + "role": "assistant", + "contents": [{ + "$type": "error", + "message": "failed", + "errorCode": "Example" + }] + }] + }, + "error": { + "code": "Example", + "message": "The operation failed." + } + } + }, + "completionReceipts": { + "failed": { + "correlationId": "failed", + "outcome": "failed", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal( + "Example", + state.Data.TerminalResults?["failed"].Error?.Code); + } + + [Fact] + public void ExplicitResultRemovalMayPrecedeScheduledExpiry() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "removed": { + "correlationId": "removed", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "resultState": "unavailable", + "resultExpiresAt": "2026-09-12T10:00:00Z", + "resultUnavailableAt": "2026-09-11T11:00:00Z" + } + } + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal( + DateTimeOffset.Parse("2026-09-11T11:00:00Z"), + state.Data.CompletionReceipts?["removed"].ResultUnavailableAt); + } + + [Fact] + public void TerminalErrorLengthCountsUnicodeScalars() + { + DurableAgentState state = CreateEmptyRevisedState(); + const string CorrelationId = "failed"; + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:00+00:00"); + state.Data.TerminalResults![CorrelationId] = new() + { + CorrelationId = CorrelationId, + Outcome = DurableAgentStateCompletionReceipt.FailedOutcome, + CompletedAt = completedAt, + Response = new(), + Error = new() + { + Code = "Example", + Message = string.Concat(Enumerable.Repeat("\U0001F600", 10_000)), + }, + }; + state.Data.CompletionReceipts![CorrelationId] = new() + { + CorrelationId = CorrelationId, + Outcome = DurableAgentStateCompletionReceipt.FailedOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }; + + string json = Serialize(state); + + Assert.Equal( + 10_000, + Deserialize(json).Data.TerminalResults![CorrelationId].Error!.Message.EnumerateRunes().Count()); + } + + [Fact] + public void TerminalResponseMetadataRequiresValidKeys() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace("\"region\": \"test\"", "\"\": \"test\"", StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void HistoryBindingIsPreservedAsOpaqueRuntimeProfile() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": { + "runtime": "csharp", + "version": -1, + "ownerKind": null, + "nested": { + "$runtimeType": "inert" + } + } + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"version\":-1", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"ownerKind\":null", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"$runtimeType\":\"inert\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponsePreservesConsumerFieldsWithoutRuntimeObjects() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"); + ChatMessage message = new( + ChatRole.Assistant, + [ + new TextContent("done"), + new UriContent("https://example.test/result.json", "application/json"), + ]) + { + MessageId = "message-id", + AuthorName = "agent", + }; + AgentResponse response = new([message]) + { + CreatedAt = completedAt, + ResponseId = "response-id", + AgentId = "agent-id", + FinishReason = new ChatFinishReason("stop"), + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), + Usage = new UsageDetails + { + InputTokenCount = 4, + OutputTokenCount = 2, + TotalTokenCount = 6, + }, + AdditionalProperties = new() + { + ["region"] = "test", + ["attempt"] = 2, + }, + RawRepresentation = new object(), + }; + + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + completedAt); + AgentResponse restored = Assert.IsType(stored.Response).ToResponse(); + + Assert.Equal("response-id", restored.ResponseId); + Assert.Equal("agent-id", restored.AgentId); + Assert.Equal("stop", restored.FinishReason?.Value); + Assert.Equal(completedAt, restored.CreatedAt); + Assert.Equal([1, 2, 3], restored.ContinuationToken?.ToBytes().ToArray()); + Assert.Equal(6, restored.Usage?.TotalTokenCount); + Assert.Equal("test", Assert.IsType(restored.AdditionalProperties?["region"]).GetString()); + Assert.Equal(2, Assert.IsType(restored.AdditionalProperties?["attempt"]).GetInt32()); + Assert.Null(restored.RawRepresentation); + ChatMessage restoredMessage = Assert.Single(restored.Messages); + Assert.Equal("message-id", restoredMessage.MessageId); + Assert.Collection( + restoredMessage.Contents, + content => Assert.Equal("done", Assert.IsType(content).Text), + content => + { + UriContent uri = Assert.IsType(content); + Assert.Equal("https://example.test/result.json", uri.Uri.ToString()); + Assert.Equal("application/json", uri.MediaType); + }); + } + + [Theory] + [InlineData("null", JsonValueKind.Null)] + [InlineData("false", JsonValueKind.False)] + [InlineData("0", JsonValueKind.Number)] + [InlineData("\"\"", JsonValueKind.String)] + [InlineData("[]", JsonValueKind.Array)] + [InlineData("{}", JsonValueKind.Object)] + public void TerminalResponsePreservesPresentStructuredValue(string valueJson, JsonValueKind expectedKind) + { + using JsonDocument valueDocument = JsonDocument.Parse(valueJson); + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse(), + DateTimeOffset.Parse("2026-09-11T10:00:00+00:00"), + structuredValue: valueDocument.RootElement); + + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult); + DurableAgentStateTerminalResult restored = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult)); + + Assert.Contains("\"value\":", json, StringComparison.Ordinal); + Assert.Equal(expectedKind, Assert.IsType(restored.Response).Value.ValueKind); + } + + [Fact] + public void TerminalResponsePreservesAbsentStructuredValue() + { + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse(), + DateTimeOffset.Parse("2026-09-11T10:00:00+00:00")); + + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult); + + Assert.DoesNotContain("\"value\"", json, StringComparison.Ordinal); + Assert.Equal( + JsonValueKind.Undefined, + Assert.IsType(stored.Response).Value.ValueKind); + } + + [Fact] + public void TerminalResponseRejectsArbitraryRuntimeMetadata() + { + AgentResponse response = new() + { + AdditionalProperties = new() + { + ["unsupported"] = new object(), + }, + }; + + InvalidOperationException exception = Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + + Assert.Contains("unsupported runtime type", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponseRejectsArbitraryMessageMetadata() + { + ChatMessage message = new(ChatRole.Assistant, "done") + { + AdditionalProperties = new() + { + ["unsupported"] = new object(), + }, + }; + + InvalidOperationException exception = Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse([message]), + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + + Assert.Contains("unsupported runtime type", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponseRejectsRuntimeBackedJsonNodeMetadata() + { + AgentResponse response = new() + { + AdditionalProperties = new() + { + ["unsupported"] = JsonValue.Create(new Dictionary + { + ["runtimeValue"] = 42, + }), + }, + }; + + Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + } + + [Fact] + public void NonCanonicalContinuationTokenIsRejected() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace("\"AQID\"", "\"AQ ID\"", StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalResultClonesJsonElementContent() + { + DurableAgentStateTerminalResult result; + using (JsonDocument document = JsonDocument.Parse("""{"value":1}""")) + { + ChatMessage message = new( + ChatRole.Assistant, + [new FunctionResultContent("call-1", document.RootElement)]); + result = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse([message]), + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00")); + } + + AgentResponse restored = Assert.IsType(result.Response).ToResponse(); + FunctionResultContent content = + Assert.IsType(Assert.Single(Assert.Single(restored.Messages).Contents)); + Assert.Equal(1, Assert.IsType(content.Result).GetProperty("value").GetInt32()); + } + + [Fact] + public void TerminalResultIsDetachedFromTranscriptAndSourceResponse() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"); + ChatMessage sourceMessage = new(ChatRole.Assistant, "original"); + AgentResponse response = new([sourceMessage]); + DurableAgentStateTerminalResult result = DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + completedAt); + DurableAgentStateResponse transcript = DurableAgentStateResponse.FromResponse("correlation", response); + + sourceMessage.Contents.Clear(); + transcript.Messages[0].MessageId = "transcript-mutated"; + + DurableAgentStateMessage resultMessage = + Assert.Single(Assert.IsType(result.Response).Messages); + Assert.Single(resultMessage.Contents); + Assert.Equal("durable_result_correlation_0", resultMessage.MessageId); + } + + [Fact] + public void VersionOneStateCannotWriteRevisedFields() + { + DurableAgentState state = new() + { + Data = new() + { + TerminalResults = new Dictionary(), + }, + }; + + Assert.Throws(() => Serialize(state)); + } + + [Theory] + [InlineData("terminalResults")] + [InlineData("completionReceipts")] + [InlineData("historyBinding")] + public void LegacyStateRejectsPresentNullRevisedFields(string propertyName) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "{{propertyName}}": null + } + } + """; + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void RevisedStatePreservesNullHistoryProfileWhenPresent() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": null + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"historyBinding\":null", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("""{"schemaVersion":"1.2.0","extensionData":null,"data":{"conversationHistory":[]}}""")] + [InlineData("""{"schemaVersion":"1.2.0","data":{"conversationHistory":[],"extensionData":null}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultExpiresAt":null,"response":{"messages":[]}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"extensionData":null}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[{"$type":"request","responseSchema":null}],"terminalResults":{},"completionReceipts":{}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[{"$type":"request","messages":[{"role":"user","createdAt":null}]}],"terminalResults":{},"completionReceipts":{}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"usage":null}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"usage":{"inputTokenCount":null}}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + public void ExplicitNullKnownFieldsAreRejected(string json) + { + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalErrorDetailsPreservesAbsentAndExplicitNull() + { + const string ExplicitNull = """ + { + "code": "Example", + "message": "failed", + "details": null + } + """; + + DurableAgentStateTerminalError present = Assert.IsType( + JsonSerializer.Deserialize( + ExplicitNull, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError)); + DurableAgentStateTerminalError absent = new() + { + Code = "Example", + Message = "failed", + }; + string presentJson = JsonSerializer.Serialize( + present, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError); + string absentJson = JsonSerializer.Serialize( + absent, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError); + + Assert.Equal(JsonValueKind.Null, present.Details.ValueKind); + Assert.Contains("\"details\":null", presentJson, StringComparison.Ordinal); + Assert.DoesNotContain("\"details\"", absentJson, StringComparison.Ordinal); + } + + [Fact] + public void IngestionPositionsMustBeNonNegative() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "ingestedPositions": { + "producer": -1 + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Fact] + public void TruncationRequiresCompleteValidEvidence() + { + const string MissingFields = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": {} + } + } + """; + DurableAgentState invalidState = new() + { + Data = new() + { + Truncation = new() + { + EvictedMessageCount = 1, + FirstEvictedAt = DateTimeOffset.Parse("2026-09-11T11:00:00+00:00"), + LastEvictedAt = DateTimeOffset.Parse("2026-09-11T10:00:00+00:00"), + }, + }, + }; + + Assert.Throws(() => Deserialize(MissingFields)); + Assert.Throws(() => Serialize(invalidState)); + } + + [Fact] + public void TruncationUnknownEvidenceRoundTrips() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": { + "evictedMessageCount": 2, + "firstEvictedAt": "2026-09-11T10:00:00Z", + "lastEvictedAt": "2026-09-11T11:00:00Z", + "futureEvidence": 42 + } + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"futureEvidence\":42", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void MailboxCrossMapComparisonIsAlwaysOrdinal() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-11T10:00:00Z"); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new() + { + TerminalResults = new Dictionary( + StringComparer.OrdinalIgnoreCase) + { + ["Case-ID"] = new() + { + CorrelationId = "Case-ID", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + Response = new(), + }, + }, + CompletionReceipts = new Dictionary( + StringComparer.OrdinalIgnoreCase) + { + ["case-id"] = new() + { + CorrelationId = "case-id", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }, + }, + }, + }; + + Assert.Throws(() => Serialize(state)); + } + + [Theory] + [InlineData( + "\"completedAt\": \"2026-09-10T05:00:03+00:00\"", + "\"completedAt\": \"2026-09-10T05:00:03\"")] + [InlineData( + "\"resultExpiresAt\": \"2026-09-11T05:00:03+00:00\"", + "\"resultExpiresAt\": \"2026-09-11T05:00:03\"")] + [InlineData( + "\"resultUnavailableAt\": \"2026-09-10T05:00:04+00:00\"", + "\"resultUnavailableAt\": \"2026-09-10T05:00:04\"")] + public void RevisedMailboxRequiresOffsetBearingRfc3339Timestamps( + string original, + string invalid) + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace(original, invalid, StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void UnavailableReceiptIsTimestampValidatedWithoutTerminalResults() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-11T11:00:00" + } + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Fact] + public void LegacyTruncationRequiresOffsetAndSeconds() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": { + "evictedMessageCount": 1, + "firstEvictedAt": "2026-09-11T10:00Z", + "lastEvictedAt": "2026-09-11T11:00:00Z" + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Theory] + [InlineData("""{"$type":"text","text":null}""")] + [InlineData("""{"$type":"functionCall","callId":"c","name":null}""")] + [InlineData("""{"$type":"uri","uri":"https://example.test","mediaType":null}""")] + [InlineData("""{"$type":"usage","usage":{"inputTokenCount":null}}""")] + [InlineData("""{"$type":"usage","usage":{"extensionData":null}}""")] + public void RevisedStateRejectsMalformedKnownContent(string contentJson) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [{{contentJson}}] + }] + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("inputTokenCount")] + [InlineData("outputTokenCount")] + [InlineData("totalTokenCount")] + [InlineData("extensionData")] + public void LegacyUsageContentRejectsExplicitNullKnownFields(string propertyName) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [{ + "$type": "usage", + "usage": { + "{{propertyName}}": null + } + }] + }] + }] + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("\"authorName\":null")] + [InlineData("\"messageId\":null")] + [InlineData("\"createdAt\":null")] + [InlineData("\"extensionData\":null")] + public void TerminalMessagesRejectExplicitNullKnownFields(string property) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-12T00:00:00Z", + "response": { + "messages": [{ + "role": "assistant", + {{property}} + }] + } + } + }, + "completionReceipts": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-12T00:00:00Z", + "resultState": "available" + } + } + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void IdentifierLengthCountsUnicodeScalars() + { + string providerKey = string.Concat(Enumerable.Repeat("\U0001F600", 200)); + DurableAgentState state = CreateEmptyRevisedState( + JsonSerializer.SerializeToElement(new + { + ownerKind = "historyProvider", + providerKey, + })); + + string json = Serialize(state); + DurableAgentState restored = Deserialize(json); + + Assert.Equal(providerKey, restored.Data.HistoryBinding.GetProperty("providerKey").GetString()); + } + + [Fact] + public void LosslessFixturePreservesDeveloperRoleArgumentsUriOpaqueContentAndValue() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0-lossless.json")); + + DurableAgentState state = Deserialize(json); + string roundTrip = Serialize(state); + DurableAgentStateRequest request = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + Assert.Equal("developer", Assert.Single(request.Messages).Role); + + DurableAgentStateTerminalResponse response = Assert.IsType( + state.Data.TerminalResults?["corr-lossless"].Response); + DurableAgentStateMessage message = Assert.Single(response.Messages); + DurableAgentStateFunctionCallContent functionCall = + Assert.IsType(message.Contents[0]); + DurableAgentStateUriContent uri = Assert.IsType(message.Contents[1]); + DurableAgentStateUnknownContent unknown = + Assert.IsType(message.Contents[2]); + + Assert.Equal(" { \"partial\": ", functionCall.Arguments.GetString()); + Assert.Null(uri.MediaType); + Assert.Equal("opaque-data-only", unknown.Content.GetProperty("$runtimeType").GetString()); + Assert.Equal(JsonValueKind.False, response.Value.ValueKind); + FunctionCallContent runtimeFunctionCall = + Assert.IsType(functionCall.ToAIContent()); + Assert.Equal(" { \"partial\": ", runtimeFunctionCall.RawRepresentation); + Assert.Throws(() => uri.ToAIContent()); + using JsonDocument roundTripDocument = JsonDocument.Parse(roundTrip); + Assert.Equal( + " { \"partial\": ", + roundTripDocument.RootElement.GetProperty("data") + .GetProperty("terminalResults") + .GetProperty("corr-lossless") + .GetProperty("response") + .GetProperty("messages")[0] + .GetProperty("contents")[0] + .GetProperty("arguments") + .GetString()); + Assert.DoesNotContain("\"mediaType\"", JsonSerializer.Serialize( + uri, + DurableAgentStateJsonContext.Default.DurableAgentStateUriContent), StringComparison.Ordinal); + } + + [Fact] + public void PrunedFixturePreservesExpiredOutcomeOpaqueSessionAndHighestSeenPosition() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0-pruned.json")); + + DurableAgentState state = Deserialize(json); + DurableAgentStateCompletionReceipt receipt = + Assert.IsType(state.Data.CompletionReceipts?["corr-pruned"]); + + Assert.Equal(DurableAgentStateCompletionReceipt.SucceededOutcome, receipt.Outcome); + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, receipt.ResultState); + Assert.False(state.Data.TerminalResults?.ContainsKey("corr-pruned")); + Assert.Equal(3, state.Data.IngestedPositions?["example-producer"]); + Assert.Equal( + "opaque-user-data", + state.Data.Session?.GetProperty("exampleContinuation").GetProperty("$runtimeType").GetString()); + Assert.Equal(4, state.Data.Truncation?.EvictedMessageCount); + } + + [Theory] + [InlineData("null", JsonValueKind.Null)] + [InlineData("\"verbatim\"", JsonValueKind.String)] + [InlineData("[0,false,null]", JsonValueKind.Array)] + public void ExplicitOpaqueJsonContentRoundTripsLosslessly(string contentJson, JsonValueKind expectedKind) + { + string json = $$""" + { + "$type": "unknown", + "content": {{contentJson}} + } + """; + + DurableAgentStateUnknownContent content = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateContent)); + string roundTrip = JsonSerializer.Serialize( + content, + DurableAgentStateJsonContext.Default.DurableAgentStateUnknownContent); + + using JsonDocument document = JsonDocument.Parse(roundTrip); + Assert.Equal(expectedKind, content.Content.ValueKind); + Assert.True(JsonElement.DeepEquals( + JsonDocument.Parse(contentJson).RootElement, + document.RootElement.GetProperty("content"))); + } + + [Fact] + public void KnownContentPreservesExplicitNullVersusAbsent() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [ + { "$type": "error", "details": null }, + { "$type": "functionResult", "callId": "null", "result": null }, + { "$type": "functionResult", "callId": "absent" } + ] + }] + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement contents = document.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[0] + .GetProperty("messages")[0] + .GetProperty("contents"); + + Assert.Equal(JsonValueKind.Null, contents[0].GetProperty("details").ValueKind); + Assert.Equal(JsonValueKind.Null, contents[1].GetProperty("result").ValueKind); + Assert.False(contents[2].TryGetProperty("result", out _)); + } + + private static DurableAgentState CreateEmptyRevisedState(JsonElement binding = default) + { + return new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new() + { + ConversationHistory = [], + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + HistoryBinding = binding, + }, + }; + } + + private static string CreateRevisedJson( + string resultOutcome, + string receiptOutcome, + string resultState, + bool includeResult = true) + { + string result = includeResult + ? $$""" + "correlation": { + "correlationId": "correlation", + "outcome": "{{resultOutcome}}", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [] } + } + """ + : string.Empty; + string unavailableAt = resultState == DurableAgentStateCompletionReceipt.UnavailableResult + ? """, "resultUnavailableAt": "2026-09-10T05:00:01+00:00" """ + : string.Empty; + + return $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { {{result}} }, + "completionReceipts": { + "correlation": { + "correlationId": "correlation", + "outcome": "{{receiptOutcome}}", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "{{resultState}}"{{unavailableAt}} + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + } + + private static DurableAgentState Deserialize(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("schemaVersion").GetString() == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateJsonConverter.DeserializeRevisedContract(json) + : Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + private static string Serialize(DurableAgentState state) => + state.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateJsonConverter.SerializeRevisedContract(state) + : JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + private static bool IsSchemaOnlyLegacyEntryCase(JsonElement state) + { + if (state.GetProperty("schemaVersion").GetString() == DurableAgentState.RevisedSchemaVersion || + !state.GetProperty("data").TryGetProperty("conversationHistory", out JsonElement history)) + { + return false; + } + + return history.EnumerateArray().Any(entry => + entry.ValueKind == JsonValueKind.Object && + !entry.TryGetProperty("$type", out _)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs index 343644d..85a72f8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs @@ -8,6 +8,19 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateMessageTests { + [Fact] + public void ProductionMappingRejectsV2OnlyDeveloperRole() + { + ChatMessage message = new(new ChatRole("developer"), "instruction"); + + Assert.Throws( + () => DurableAgentStateMessage.FromChatMessage(message)); + + DurableAgentStateMessage revised = + DurableAgentStateMessage.FromTerminalChatMessage(message); + Assert.Equal("developer", revised.Role); + } + [Fact] public void MessageSerializationDeserialization() { @@ -44,4 +57,216 @@ public void MessageSerializationDeserialization() Assert.Equal(textContent.Text, convertedTextContent.Text); } + + [Fact] + public void MessageIdAndAdditionalPropertiesRoundTrip() + { + ChatMessage message = new(ChatRole.User, "hello") + { + MessageId = "message-1", + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["excluded"] = true, + ["summary"] = "summary-1", + }, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + ChatMessage restored = stored.ToChatMessage(); + + Assert.Equal("message-1", restored.MessageId); + Assert.NotNull(restored.AdditionalProperties); + Assert.Equal(JsonValueKind.True, Assert.IsType(restored.AdditionalProperties["excluded"]).ValueKind); + Assert.Equal("summary-1", Assert.IsType(restored.AdditionalProperties["summary"]).GetString()); + } + + [Fact] + public void StandaloneConversionDoesNotInventRandomIdentity() + { + DurableAgentStateMessage stored = + DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.User, "hello")); + + Assert.Null(stored.MessageId); + Assert.Null(stored.ToChatMessage().MessageId); + } + + [Fact] + public void EntryFactoriesSynthesizeDeterministicMessageIds() + { + RunRequest request = new("hello") { CorrelationId = "correlation" }; + + DurableAgentStateRequest first = DurableAgentStateRequest.FromRunRequest(request); + DurableAgentStateRequest second = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("durable_request_correlation_0", first.Messages[0].MessageId); + Assert.Equal(first.Messages[0].MessageId, second.Messages[0].MessageId); + } + + [Fact] + public void EntryFactoriesPreserveProducerMessageIds() + { + RunRequest request = new( + [new ChatMessage(ChatRole.User, "hello") { MessageId = "producer-id" }]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("producer-id", stored.Messages[0].MessageId); + } + + [Fact] + public void RequestFactoryUsesStoredPositionsWithoutFiltering() + { + RunRequest request = new( + [ + new ChatMessage(ChatRole.User, [new AIContent()]), + new ChatMessage(ChatRole.User, "hello"), + ]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal( + ["durable_request_correlation_0", "durable_request_correlation_1"], + stored.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyCorrelationlessCompactionUsesStoredPositions() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + DurableAgentStateCompaction compaction = new() + { + CreatedAt = createdAt, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextContent { Text = "summary" }], + }, + ], + }; + + DurableAgentStateMessageIdentity.EnsureMessageIds([compaction]); + + Assert.Equal( + [ + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + ], + compaction.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyEmptyCorrelationIdUsesTimestampScope() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + + string messageId = DurableAgentStateMessageIdentity.Create( + "compaction", + string.Empty, + createdAt, + storedIndex: 1); + + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + messageId); + } + + [Fact] + public void AdditionalPropertiesAreCopiedOnBothConversions() + { + AdditionalPropertiesDictionary producerProperties = new() + { + ["marker"] = "original", + }; + ChatMessage message = new(ChatRole.User, "hello") + { + AdditionalProperties = producerProperties, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + producerProperties["marker"] = "producer-mutated"; + ChatMessage restored = stored.ToChatMessage(); + restored.AdditionalProperties!["marker"] = "consumer-mutated"; + + Assert.Equal("original", stored.AdditionalProperties?["marker"].GetString()); + } + + [Theory] + [InlineData("2026-07-27T12:34:56+00:00", "2026-07-27T12:34:56+00:00")] + [InlineData("2026-07-27T12:34:56.1234567+00:00", "2026-07-27T12:34:56.123456+00:00")] + [InlineData("2026-07-27T12:34:56.1000000+05:30", "2026-07-27T12:34:56.100000+05:30")] + public void CorrelationlessTimestampScopeMatchesPythonIsoFormat(string input, string expected) + { + DateTimeOffset timestamp = DateTimeOffset.Parse(input); + + string actual = DurableAgentStateMessageIdentity.FormatPythonIsoTimestamp(timestamp); + + Assert.Equal(expected, actual); + } + + [Fact] + public void VersionOnePointTwoFixtureRoundTrips() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "correlationId": "request-1", + "createdAt": "2026-01-01T00:00:00Z", + "messages": [{ + "role": "user", + "messageId": "message-1", + "extensionData": { "excluded": true }, + "contents": [{ "$type": "text", "text": "hello" }] + }] + }], + "session": { + "conversationId": "service-1", + "stateBag": {} + }, + "ingestedPositions": { + "input": 0, + "writer": 1 + }, + "truncation": { + "evictedMessageCount": 2, + "firstEvictedAt": "2026-01-01T00:00:00Z", + "lastEvictedAt": "2026-01-02T00:00:00Z" + } + } + } + """; + + DurableAgentState? state = JsonSerializer.Deserialize( + Json, + DurableAgentStateJsonContext.Default.DurableAgentState); + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.NotNull(state); + Assert.Equal("1.2.0", state.SchemaVersion); + Assert.Equal("message-1", state.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal(0, state.Data.IngestedPositions?["input"]); + Assert.Equal(1, state.Data.IngestedPositions?["writer"]); + Assert.Contains("\"conversationId\":\"service-1\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"ingestedPositions\":{\"input\":0,\"writer\":1}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"evictedMessageCount\":2", roundTrip, StringComparison.Ordinal); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs index a974f9d..66caf9f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateResponseTests { [Fact] - public void FromResponseDropsMessagesContainingOnlyOpaqueContent() + public void FromResponsePreservesMessagesContainingOnlyOpaqueContent() { // Arrange: one message with real text, one with only opaque AIContent ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!") @@ -32,15 +32,14 @@ public void FromResponseDropsMessagesContainingOnlyOpaqueContent() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response); - // Assert: only the useful message survives - DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); - Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); + Assert.Equal(2, durableResponse.Messages.Count); + Assert.Equal(ChatRole.Assistant.Value, durableResponse.Messages[1].Role); - // Round-trip to verify the content is correct AgentResponse convertedResponse = durableResponse.ToResponse(); - ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages); - TextContent textContent = Assert.IsType(Assert.Single(convertedMessage.Contents)); + Assert.Equal(2, convertedResponse.Messages.Count); + TextContent textContent = Assert.IsType(Assert.Single(convertedResponse.Messages[0].Contents)); Assert.Equal("Hello, world!", textContent.Text); + Assert.IsType(Assert.Single(convertedResponse.Messages[1].Contents)); } [Fact] @@ -68,7 +67,7 @@ public void FromResponseKeepsMessagesWithMixedContent() } [Fact] - public void FromResponseDropsAllMessagesWhenAllAreOpaque() + public void FromResponsePreservesAllMessagesWhenAllAreOpaque() { // Arrange: all messages contain only opaque AIContent ChatMessage opaque1 = new(ChatRole.Assistant, [ @@ -90,8 +89,7 @@ public void FromResponseDropsAllMessagesWhenAllAreOpaque() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response); - // Assert: no messages stored - Assert.Empty(durableResponse.Messages); + Assert.Equal(2, durableResponse.Messages.Count); } [Fact] @@ -139,4 +137,133 @@ public void FromResponseKeepsBaseAIContentWithAdditionalProperties() // Assert: message is kept because the AIContent has additional properties Assert.Single(durableResponse.Messages); } + + [Fact] + public void FromResponseUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AdditionalProperties = new() { ["kind"] = "metadata" }, + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + AgentResponse response = new([metadataOnly, text]); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", response); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("durable_response_correlation_0", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromMessagesUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + MessageId = "producer-metadata-id", + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromMessages("correlation", [metadataOnly, text]); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("producer-metadata-id", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromResponsePreservesProducerIdAfterFiltering() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []); + ChatMessage text = new(ChatRole.Assistant, "kept") + { + MessageId = "producer-id", + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly, text])); + + Assert.Equal("producer-id", stored.Messages[1].MessageId); + } + + [Fact] + public void MetadataOnlyResponsePersistsAndRoundTrips() + { + DateTimeOffset createdAt = DateTimeOffset.Parse("2026-09-06T12:34:56+00:00"); + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AuthorName = "agent", + CreatedAt = createdAt, + MessageId = "producer-message-id", + AdditionalProperties = new() + { + ["trace"] = "value", + }, + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly])); + string json = System.Text.Json.JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse); + DurableAgentStateResponse restored = Assert.IsType( + System.Text.Json.JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse)); + ChatMessage roundTripped = Assert.Single(restored.ToResponse().Messages); + + Assert.Empty(roundTripped.Contents); + Assert.Equal(ChatRole.Assistant, roundTripped.Role); + Assert.Equal("agent", roundTripped.AuthorName); + Assert.Equal(createdAt, roundTripped.CreatedAt); + Assert.Equal("producer-message-id", roundTripped.MessageId); + Assert.Equal( + "value", + Assert.IsType( + roundTripped.AdditionalProperties?["trace"]).GetString()); + } + + [Fact] + public void ToResponseRetainsMetadataOnlyMessageForPolling() + { + DurableAgentStateResponse stored = new() + { + CorrelationId = "correlation", + CreatedAt = DateTimeOffset.Parse("2026-09-06T12:00:00+00:00"), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + MessageId = "pollable-metadata", + AdditionalProperties = new Dictionary + { + ["status"] = System.Text.Json.JsonSerializer.SerializeToElement("complete"), + }, + Contents = [], + }, + ], + }; + + ChatMessage message = Assert.Single(stored.ToResponse().Messages); + + Assert.Equal("pollable-metadata", message.MessageId); + Assert.Empty(message.Contents); + Assert.Equal( + "complete", + Assert.IsType( + message.AdditionalProperties?["status"]).GetString()); + } + + [Fact] + public void EmptyResponseGetsCreatedAtWithoutThrowing() + { + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse()); + + Assert.Empty(stored.Messages); + Assert.NotEqual(default, stored.CreatedAt); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs index f8ce5c6..8a0f0a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -2,11 +2,22 @@ using System.Text.Json; using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateTests { + [Fact] + public void NewStateDefaultsToCurrentSchemaVersion() + { + DurableAgentState state = new(); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, state.SchemaVersion); + Assert.Equal("1.2.0", state.SchemaVersion); + Assert.Equal("2.0.0", DurableAgentState.RevisedSchemaVersion); + } + [Fact] public void InvalidVersion() { @@ -22,13 +33,106 @@ public void InvalidVersion() () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); } + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + public void DeclaredSchemaVersionsAreAccepted(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + Assert.Equal(version, state.SchemaVersion); + } + + [Theory] + [InlineData("1.2")] + [InlineData("1.2.0.0")] + [InlineData("v1.2.0")] + [InlineData("")] + [InlineData(" ")] + [InlineData("-1.2.0")] + [InlineData("1.-2.0")] + [InlineData("1.2.-3")] + [InlineData("01.2.0")] + [InlineData("1.02.0")] + [InlineData("1.2.00")] + [InlineData("1.2.0-alpha")] + [InlineData("1.2.0+build")] + [InlineData("1.2.0-alpha+build")] + [InlineData("1.0.7")] + [InlineData("1.1.9")] + [InlineData("1.2.7")] + [InlineData("1.3.0")] + [InlineData("2.0.1")] + [InlineData("2.1.0")] + [InlineData("1.2147483648.0")] + [InlineData("1.2.2147483648")] + public void InvalidOrUndeclaredSchemaVersionIsRejected(string version) + { + string json = $$""" + { + "schemaVersion": {{JsonSerializer.Serialize(version)}}, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void NonStringSchemaVersionIsRejected() + { + const string JsonText = """ + { + "schemaVersion": 10200, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + JsonText, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void InvalidSchemaVersionCannotBeSerialized() + { + DurableAgentState state = new() + { + SchemaVersion = "1.2", + }; + + Assert.Throws( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + [Fact] - public void BreakingVersion() + public void UnsupportedMajorVersion() { // Arrange const string JsonText = """ { - "schemaVersion": "2.0.0" + "schemaVersion": "3.0.0" } """; @@ -53,7 +157,7 @@ public void MissingData() } [Fact] - public void ExtraData() + public void UnknownDataPropertiesRoundTrip() { // Arrange const string JsonText = """ @@ -70,10 +174,10 @@ public void ExtraData() DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState); // Assert - Assert.NotNull(state?.Data?.ExtensionData); + Assert.NotNull(state?.Data?.UnknownProperties); - Assert.True(state.Data.ExtensionData!.ContainsKey("extraField")); - Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString()); + Assert.True(state.Data.UnknownProperties!.ContainsKey("extraField")); + Assert.Equal("someValue", state.Data.UnknownProperties["extraField"].ToString()); // Act string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); @@ -86,6 +190,137 @@ public void ExtraData() Assert.Equal("someValue", extraFieldElement.ToString()); } + [Fact] + public void OpaqueSessionIsClonedFromCallerOwnedJson() + { + DurableAgentState state = new(); + using (JsonDocument document = JsonDocument.Parse( + """{"conversationId":"service-1","$runtimeType":"Untrusted.Type, Untrusted.Assembly"}""")) + { + state.Data.Session = document.RootElement; + } + + string json = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + DurableAgentState restored = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + JsonElement session = Assert.IsType(restored.Data.Session); + Assert.Equal(JsonValueKind.Object, session.ValueKind); + Assert.Equal("service-1", session.GetProperty("conversationId").GetString()); + Assert.Equal( + "Untrusted.Type, Untrusted.Assembly", + session.GetProperty("$runtimeType").GetString()); + Assert.IsType(restored.Data.Session); + } + + [Theory] + [InlineData("null")] + [InlineData("\"session\"")] + [InlineData("[]")] + [InlineData("42")] + public void OpaqueSessionMustBeAJsonObject(string sessionJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "session": {{sessionJson}} + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void DeclaredExtensionDataAndUnknownPropertiesRoundTripIndependently() + { + const string JsonText = """ + { + "schemaVersion": "1.2.0", + "extensionData": { "rootMetadata": "root" }, + "futureRoot": 1, + "data": { + "extensionData": { "dataMetadata": "data" }, + "futureData": 2, + "conversationHistory": [{ + "$type": "response", + "correlationId": "correlation", + "createdAt": "2026-09-07T12:00:00+00:00", + "extensionData": { "entryMetadata": "entry" }, + "futureEntry": 3, + "usage": { + "extensionData": { "providerCount": 4 }, + "futureUsage": 5 + }, + "messages": [{ + "role": "assistant", + "extensionData": { "messageMetadata": "message" }, + "futureMessage": 6, + "contents": [{ + "$type": "text", + "text": "answer", + "futureContent": 7 + }] + }] + }] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + DurableAgentStateResponse response = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + DurableAgentStateMessage message = Assert.Single(response.Messages); + DurableAgentStateTextContent content = + Assert.IsType(Assert.Single(message.Contents)); + DurableAgentStateUsage usage = Assert.IsType(response.Usage); + + Assert.Equal("root", state.ExtensionData?["rootMetadata"].GetString()); + Assert.Equal(1, state.UnknownProperties?["futureRoot"].GetInt32()); + Assert.Equal("data", state.Data.ExtensionData?["dataMetadata"].GetString()); + Assert.Equal(2, state.Data.UnknownProperties?["futureData"].GetInt32()); + Assert.Equal("entry", response.ExtensionData?["entryMetadata"].GetString()); + Assert.Equal(3, response.UnknownProperties?["futureEntry"].GetInt32()); + Assert.Equal("message", message.AdditionalProperties?["messageMetadata"].GetString()); + Assert.Equal(6, message.UnknownProperties?["futureMessage"].GetInt32()); + Assert.Equal(7, content.UnknownProperties?["futureContent"].GetInt32()); + Assert.Equal(4, usage.ExtensionData?["providerCount"].GetInt32()); + Assert.Equal(5, usage.UnknownProperties?["futureUsage"].GetInt32()); + + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement root = document.RootElement; + JsonElement data = root.GetProperty("data"); + JsonElement entry = data.GetProperty("conversationHistory")[0]; + JsonElement roundTrippedMessage = entry.GetProperty("messages")[0]; + JsonElement roundTrippedContent = roundTrippedMessage.GetProperty("contents")[0]; + JsonElement roundTrippedUsage = entry.GetProperty("usage"); + + Assert.Equal("root", root.GetProperty("extensionData").GetProperty("rootMetadata").GetString()); + Assert.Equal(1, root.GetProperty("futureRoot").GetInt32()); + Assert.Equal("data", data.GetProperty("extensionData").GetProperty("dataMetadata").GetString()); + Assert.Equal(2, data.GetProperty("futureData").GetInt32()); + Assert.Equal("entry", entry.GetProperty("extensionData").GetProperty("entryMetadata").GetString()); + Assert.Equal(3, entry.GetProperty("futureEntry").GetInt32()); + Assert.Equal( + "message", + roundTrippedMessage.GetProperty("extensionData").GetProperty("messageMetadata").GetString()); + Assert.Equal(6, roundTrippedMessage.GetProperty("futureMessage").GetInt32()); + Assert.Equal(7, roundTrippedContent.GetProperty("futureContent").GetInt32()); + Assert.Equal(4, roundTrippedUsage.GetProperty("extensionData").GetProperty("providerCount").GetInt32()); + Assert.Equal(5, roundTrippedUsage.GetProperty("futureUsage").GetInt32()); + } + [Fact] public void BasicState() { @@ -117,7 +352,7 @@ public void BasicState() "createdAt": "2024-01-01T12:01:00Z", "messages": [ { - "role": "agent", + "role": "assistant", "contents": [ { "$type": "text", @@ -160,11 +395,208 @@ public void BasicState() Assert.Equal("12345", entry.CorrelationId); Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:01:00Z"), entry.CreatedAt); Assert.Single(entry.Messages); - Assert.Equal("agent", entry.Messages[0].Role); + Assert.Equal("assistant", entry.Messages[0].Role); Assert.Single(entry.Messages[0].Contents); DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents); DurableAgentStateTextContent textContent = Assert.IsType(content); Assert.Equal("Hi user!", textContent.Text); }); } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + public void CloneForWritePromotesOlderCompatibleStateToCurrentVersion(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [], + "ingestedPositions": { "writer": 2 } + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState promoted = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + promoted, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, promoted.SchemaVersion); + Assert.Equal(2, promoted.Data.IngestedPositions?["writer"]); + Assert.Contains("\"schemaVersion\":\"1.2.0\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void CloneForWritePreservesCurrentVersion() + { + const string Version = "1.2.0"; + string json = $$""" + { + "schemaVersion": "{{Version}}", + "data": { + "conversationHistory": [] + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState clone = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + clone, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(Version, clone.SchemaVersion); + Assert.Contains($"\"schemaVersion\":\"{Version}\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void CurrentVersionUnknownFieldsSurviveMutationAndRoundTrip() + { + const string JsonText = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [ + { + "$type": "response", + "correlationId": "future", + "createdAt": "2026-09-06T12:00:00+00:00", + "futureEntry": { "keep": true }, + "messages": [ + { + "role": "assistant", + "contents": [], + "futureMessage": [1, 2, 3] + } + ] + } + ], + "futureData": "preserve" + }, + "futureRoot": 42 + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState mutated = state.Clone(); + mutated.Data.IngestedPositions = new Dictionary { ["writer"] = 7 }; + string roundTrip = JsonSerializer.Serialize( + mutated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("1.2.0", document.RootElement.GetProperty("schemaVersion").GetString()); + Assert.Equal(42, document.RootElement.GetProperty("futureRoot").GetInt32()); + JsonElement data = document.RootElement.GetProperty("data"); + Assert.Equal("preserve", data.GetProperty("futureData").GetString()); + Assert.Equal(7, data.GetProperty("ingestedPositions").GetProperty("writer").GetInt32()); + JsonElement response = data.GetProperty("conversationHistory")[0]; + Assert.True(response.GetProperty("futureEntry").GetProperty("keep").GetBoolean()); + Assert.Equal(3, response.GetProperty("messages")[0].GetProperty("futureMessage").GetArrayLength()); + } + + [Fact] + public void SharedPythonShapeFixtureMigratesIdsAndPreservesExtensions() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-1.2-python-shape.json")); + using JsonDocument sourceDocument = JsonDocument.Parse(json); + JsonElement sourceUnknownContent = sourceDocument.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState migrated = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + migrated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("producer-request-id", migrated.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal("python-metadata-only", migrated.Data.ConversationHistory[1].Messages[0].MessageId); + Assert.Equal("durable_response_corr-python_1", migrated.Data.ConversationHistory[1].Messages[1].MessageId); + Assert.Equal("durable_response_corr-python_2", migrated.Data.ConversationHistory[1].Messages[2].MessageId); + Assert.Equal("python-unknown-content", migrated.Data.ConversationHistory[1].Messages[3].MessageId); + Assert.Equal("durable_errorResponse_corr-error_0", migrated.Data.ConversationHistory[2].Messages[0].MessageId); + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + migrated.Data.ConversationHistory[3].Messages[0].MessageId); + DurableAgentStateResponse response = + Assert.IsType(migrated.Data.ConversationHistory[1]); + ChatMessage metadataOnly = response.ToResponse().Messages[0]; + Assert.Empty(metadataOnly.Contents); + Assert.Equal("python-metadata-only", metadataOnly.MessageId); + Assert.Equal("python-agent", metadataOnly.AuthorName); + Assert.Equal( + DateTimeOffset.Parse("2026-07-27T12:34:51+00:00"), + metadataOnly.CreatedAt); + Assert.Equal( + "python", + Assert.IsType(metadataOnly.AdditionalProperties?["metadataOrigin"]).GetString()); + DurableAgentStateUnknownContent unknown = Assert.IsType( + migrated.Data.ConversationHistory[1].Messages[3].Contents[0]); + JsonElement pythonContent = unknown.Content; + Assert.Equal( + "python-owned-user-field", + pythonContent.GetProperty("$runtimeType").GetString()); + Assert.Equal("future_python_content", pythonContent.GetProperty("type").GetString()); + Assert.Equal("python-value", pythonContent.GetProperty("payload").GetString()); + Assert.Equal( + 3, + pythonContent.GetProperty("future_payload").GetProperty("nested").GetArrayLength()); + JsonElement persistedUnknownContent = document.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + Assert.True(JsonElement.DeepEquals(sourceUnknownContent, persistedUnknownContent)); + UsageDetails usage = Assert.IsType(response.ToResponse().Usage); + Assert.Equal(7, usage.AdditionalCounts?["providerCount"]); + Assert.Equal(11, usage.AdditionalCounts?["futureNumeric"]); + Assert.DoesNotContain("futureString", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureObject", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureArray", usage.AdditionalCounts?.Keys ?? []); + Assert.Contains("\"futureString\":\"seven\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureObject\":{\"count\":8}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureArray\":[9]", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"type\":\"future_python_content\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"payload\":\"python-value\"", roundTrip, StringComparison.Ordinal); + Assert.Equal(3, migrated.Data.IngestedPositions?["writer"]); + Assert.Equal("interop-fixture", migrated.ExtensionData?["rootProducer"].GetString()); + Assert.True(migrated.UnknownProperties?["futureRootProperty"].GetProperty("preserve").GetBoolean()); + Assert.Equal("python", migrated.Data.ExtensionData?["dataProducer"].GetString()); + Assert.True(migrated.Data.UnknownProperties?["futureDataProperty"].GetProperty("preserve").GetBoolean()); + Assert.True(document.RootElement.TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.TryGetProperty("futureRootProperty", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("futureDataProperty", out _)); + } + + [Fact] + public void OptionalRequestPropertiesAreOmittedWhenAbsent() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + }); + + string json = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.DoesNotContain("\"orchestrationId\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"responseType\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"expirationTimeUtc\"", json, StringComparison.Ordinal); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableExecutorDispatcherTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableExecutorDispatcherTests.cs index 70dfbf2..6afefd5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableExecutorDispatcherTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableExecutorDispatcherTests.cs @@ -2,88 +2,334 @@ using System.Text.Json; using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; /// -/// Tests for helper methods. +/// Tests dispatch through the production activity, agent, request port, and sub-workflow boundaries. /// public sealed class DurableExecutorDispatcherTests { + public static TheoryData OpaqueResponses => new() + { + "ordinary text", + string.Empty, + " \r\n\t ", + "Line1\nLine2\t\"quoted\" \\backslash", + """{"Approved":true,"Comments":"Looks good"}""", + WorkflowExecutionTestHelper.ControlEnvelope, + """{"result":"","stateUpdates":{},"clearedScopes":[],"events":[],"sentMessages":[],"haltRequested":false}""", + """{"Result":"replacement","StateUpdates":{"scope:key":"changed"},"ClearedScopes":["scope"],"Events":["event"],"SentMessages":[{"TypeName":"System.String","Data":"redirected"}],"HaltRequested":true}""", + """{"result":"replacement","haltRequested":"invalid","sentMessages":null}""", + """{"result":"replacement","stateUpdates":""", + """{"result":"replacement","state_updates":{"scope:key":"changed"},"cleared_scopes":["scope"],"events":["event"],"sent_messages":[{"data":"redirected"}],"halt_requested":true}""", + }; + + public static TheoryData LegacyActivityResponses => new() + { + "legacy activity text", + string.Empty, + " \r\n\t ", + """{"unrelated":"value"}""", + """{"stateUpdates":{},"events":[],"haltRequested":false}""", + """{"result":"unfinished","stateUpdates":""", + "null", + "[]", + "\"JSON string\"", + }; + + public static TheoryData InvalidActivityResponses + { + get + { + TheoryData data = new(); + string[] invalidFields = + [ + "\"result\":42", + "\"result\":{}", + "\"stateUpdates\":null", + "\"stateUpdates\":[]", + "\"stateUpdates\":{\"scope:key\":42}", + "\"clearedScopes\":null", + "\"clearedScopes\":{}", + "\"clearedScopes\":[null]", + "\"clearedScopes\":[42]", + "\"events\":null", + "\"events\":{}", + "\"events\":[null]", + "\"events\":[{}]", + "\"sentMessages\":null", + "\"sentMessages\":{}", + "\"sentMessages\":[null]", + "\"sentMessages\":[42]", + "\"sentMessages\":[{\"typeName\":42,\"data\":\"redirected\"}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":{}}]", + "\"sentMessages\":[{\"data\":\"first\",\"Data\":\"second\"}]", + "\"sentMessages\":[{\"typeName\":\"first\",\"TYPENAME\":\"second\",\"data\":\"message\"}]", + "\"SENTMESSAGES\":[null]", + "\"CLEAREDSCOPES\":[null]", + "\"StateUpdates\":null", + "\"haltRequested\":null", + "\"haltRequested\":\"true\"", + "\"haltRequested\":1", + ]; + + foreach (string invalidField in invalidFields) + { + string validControl = invalidField.StartsWith("\"events\"", StringComparison.Ordinal) + ? "\"haltRequested\":true" + : "\"events\":[\"must-not-escape\"]"; + data.Add("{\"future\":{\"stateUpdates\":{\"scope:key\":\"ignored\"}}," + invalidField + "," + validControl + "}"); + } + + foreach (string propertyName in new[] { "result", "stateUpdates", "clearedScopes", "events", "sentMessages", "haltRequested" }) + { + using JsonDocument document = JsonDocument.Parse(WorkflowExecutionTestHelper.ControlEnvelope); + string value = document.RootElement.GetProperty(propertyName).GetRawText(); + data.Add(WorkflowExecutionTestHelper.ControlEnvelope[..^1] + ",\"" + propertyName.ToUpperInvariant() + "\":" + value + "}"); + } + + data.Add("""{"result":"first","\u0072esult":"second","haltRequested":true}"""); + data.Add("""{"result":"valid","EVENTS":[null],"haltRequested":true}"""); + return data; + } + } + + [Theory] + [MemberData(nameof(OpaqueResponses))] + public async Task DispatchAsync_AgentResponse_RemainsOpaqueAsync(string response) + { + Mock context = WorkflowExecutionTestHelper.CreateAgentContext(response); + + DurableExecutorOutput output = await DispatchAsync(context, new("agent", IsAgenticExecutor: true)); + + AssertOpaque(response, output); + } + + [Theory] + [MemberData(nameof(OpaqueResponses))] + public async Task DispatchAsync_RequestPortResponse_RemainsOpaqueAsync(string response) + { + Mock context = new(); + context.Setup(c => c.WaitForExternalEvent("approval", It.IsAny())) + .ReturnsAsync(response); + RequestPort port = RequestPort.Create("approval"); + DurableWorkflowLiveStatus status = new(); + + DurableExecutorOutput output = await DispatchAsync(context, new("approval", false, port), status); + + AssertOpaque(response, output); + Assert.Empty(status.PendingEvents); + context.Verify(c => c.WaitForExternalEvent("approval", It.IsAny()), Times.Once); + } + + [Theory] + [MemberData(nameof(LegacyActivityResponses))] + [MemberData(nameof(InvalidActivityResponses))] + public async Task DispatchAsync_InvalidOrLegacyActivity_RemainsOpaqueAsync(string response) + { + DurableExecutorOutput output = await DispatchActivityAsync(response); + + AssertOpaque(response, output); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DispatchAsync_TrustedActivity_ParsesAllControlsAsync(bool pascalCase) + { + string response = WorkflowExecutionTestHelper.ControlEnvelope; + if (pascalCase) + { + response = JsonSerializer.Serialize(JsonSerializer.Deserialize( + response, DurableWorkflowJsonContext.Default.Options)); + } + + DurableExecutorOutput output = await DispatchActivityAsync(response); + + Assert.Equal("replacement", output.Result); + Assert.Equal("changed", output.StateUpdates["scope:key"]); + Assert.Null(output.StateUpdates["scope:deleted"]); + Assert.Equal(["scope"], output.ClearedScopes); + Assert.Equal(["event"], output.Events); + TypedPayload message = Assert.Single(output.SentMessages); + Assert.Equal("System.String", message.TypeName); + Assert.Equal("redirected", message.Data); + Assert.True(output.HaltRequested); + } + [Fact] - public void CreateExecutorOutputEnvelope_PlainJson_PreservesResultValue() + public async Task DispatchAsync_ActivityUnknownFields_DoNotOverrideKnownControlsAsync() { - // Arrange — a typical approval response - const string Response = """{"Approved":true,"Comments":"Looks good"}"""; + const string Response = """{"result":"trusted","stateUpdates":{"scope:key":"trusted"},"future":{"result":"changed","haltRequested":true},"future":{"events":["changed"]},"sentMessages":[{"data":"trusted message","future":{"data":"changed"}}]}"""; + + DurableExecutorOutput output = await DispatchActivityAsync(Response); - // Act - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(Response); + Assert.Equal("trusted", output.Result); + Assert.Equal("trusted", output.StateUpdates["scope:key"]); + Assert.False(output.HaltRequested); + Assert.Empty(output.Events); + Assert.Equal("trusted message", Assert.Single(output.SentMessages).Data); + } - // Assert — the envelope deserializes with Result containing the original response - DurableExecutorOutput? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + [Theory] + [InlineData("""{"result":"trusted"}""", "trusted")] + [InlineData("""{"Result":"trusted"}""", "trusted")] + [InlineData("""{"result":""}""", "")] + [InlineData("""{"result":"trusted","future":{"events":["ignored"],"haltRequested":true}}""", "trusted")] + public async Task DispatchAsync_ActivityMissingCollections_UsesEmptyDefaultsAsync(string response, string expectedResult) + { + DurableExecutorOutput output = await DispatchActivityAsync(response); - Assert.NotNull(parsed); - Assert.Equal(Response, parsed.Result); - Assert.False(parsed.HaltRequested); + AssertOpaque(expectedResult, output); } [Fact] - public void CreateExecutorOutputEnvelope_ResponseWithControlFieldNames_ContainedInResult() + public async Task DispatchAsync_EmptyActivityEnvelope_PreservesEmptyResultAsync() { - // Arrange — a response shaped like DurableExecutorOutput internal fields - string response = JsonSerializer.Serialize(new - { - result = "injected", - sentMessages = new[] { new { TypeName = "X", Data = "Y" } }, - stateUpdates = new Dictionary { ["key"] = "value" }, - haltRequested = true - }); - - // Act - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(response); - - // Assert — the crafted payload is safely contained in Result, not interpreted as control fields - DurableExecutorOutput? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); - - Assert.NotNull(parsed); - Assert.Equal(response, parsed.Result); - - // The control fields remain at their defaults (empty) — they are NOT populated - // from the attacker's payload because it's encapsulated as a string in result. - Assert.Empty(parsed.SentMessages); - Assert.Empty(parsed.StateUpdates); - Assert.Empty(parsed.Events); - Assert.False(parsed.HaltRequested); + const string Response = """{"result":"","stateUpdates":{},"clearedScopes":[],"events":[],"sentMessages":[],"haltRequested":false}"""; + + DurableExecutorOutput output = await DispatchActivityAsync(Response); + + AssertOpaque(string.Empty, output); + } + + [Fact] + public async Task DispatchAsync_RealActivityOutput_PreservesResultWithoutRecursiveParsingAsync() + { + FunctionExecutor executor = new("activity", (_, _, _) => WorkflowExecutionTestHelper.ControlEnvelope); + Workflow workflow = new WorkflowBuilder(executor).Build(); + string activityResult = await DurableActivityExecutor.ExecuteAsync( + workflow.ReflectExecutors()["activity"], + JsonSerializer.Serialize(new DurableActivityInput { Input = "input" }, DurableWorkflowJsonContext.Default.DurableActivityInput)); + DurableExecutorOutput produced = JsonSerializer.Deserialize( + activityResult, DurableWorkflowJsonContext.Default.DurableExecutorOutput)!; + + DurableExecutorOutput output = await DispatchActivityAsync(activityResult); + + Assert.Equal(WorkflowExecutionTestHelper.ControlEnvelope, output.Result); + Assert.Empty(output.StateUpdates); + Assert.Empty(output.ClearedScopes); + Assert.NotEmpty(produced.Events); + Assert.Equal(produced.Events, output.Events); + Assert.DoesNotContain("event", output.Events); + Assert.False(output.HaltRequested); + } + + [Fact] + public async Task DispatchAsync_RealActivityContext_ProducesTrustedControlsAsync() + { + ControlProducingExecutor executor = new(); + Workflow workflow = new WorkflowBuilder(executor).Build(); + string activityResult = await DurableActivityExecutor.ExecuteAsync( + workflow.ReflectExecutors()[executor.Id], + JsonSerializer.Serialize(new DurableActivityInput { Input = "input" }, DurableWorkflowJsonContext.Default.DurableActivityInput)); + DurableExecutorOutput produced = JsonSerializer.Deserialize( + activityResult, DurableWorkflowJsonContext.Default.DurableExecutorOutput)!; + + DurableExecutorOutput output = await DispatchActivityAsync(activityResult); + + Assert.Equal(string.Empty, output.Result); + Assert.Equal("\"trusted\"", output.StateUpdates["scope:key"]); + Assert.Null(output.StateUpdates["other:deleted"]); + Assert.Equal(["scope"], output.ClearedScopes); + TypedPayload message = Assert.Single(output.SentMessages); + Assert.Equal(typeof(string).AssemblyQualifiedName, message.TypeName); + Assert.Equal("\"trusted message\"", message.Data); + Assert.Equal(produced.Events, output.Events); + IEnumerable events = output.Events.Select( + serializedEvent => JsonSerializer.Deserialize(serializedEvent, DurableWorkflowJsonContext.Default.TypedPayload)!); + TypedPayload haltEvent = Assert.Single( + events, workflowEvent => workflowEvent.TypeName == typeof(DurableHaltRequestedEvent).AssemblyQualifiedName); + DurableHaltRequestedEvent halt = JsonSerializer.Deserialize( + haltEvent.Data!, DurableSerialization.Options)!; + Assert.Equal(executor.Id, halt.ExecutorId); + Assert.True(output.HaltRequested); } [Fact] - public void CreateExecutorOutputEnvelope_EmptyString_ProducesValidEnvelope() + public async Task DispatchAsync_SubWorkflow_UsesTypedControlsAndOpaqueResultAsync() { - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(string.Empty); + Workflow workflow = new WorkflowBuilder(new FunctionExecutor("child", (input, _, _) => input)) + .WithName("child-workflow").Build(); + Mock context = new(); + context.Setup(c => c.CallSubOrchestratorAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DurableWorkflowResult + { + Result = WorkflowExecutionTestHelper.ControlEnvelope, + Events = ["child event"], + SentMessages = [new TypedPayload { Data = "child message", TypeName = "System.String" }], + HaltRequested = true, + }); - DurableExecutorOutput? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + DurableExecutorOutput output = await DispatchAsync(context, new("child", false, SubWorkflow: workflow)); - Assert.NotNull(parsed); - Assert.Equal(string.Empty, parsed.Result); + Assert.Equal(WorkflowExecutionTestHelper.ControlEnvelope, output.Result); + Assert.Equal(["child event"], output.Events); + Assert.Equal("child message", Assert.Single(output.SentMessages).Data); + Assert.True(output.HaltRequested); + Assert.Empty(output.StateUpdates); + Assert.Empty(output.ClearedScopes); } [Fact] - public void CreateExecutorOutputEnvelope_SpecialCharacters_ProperlyEscaped() + public async Task DispatchAsync_NullSubWorkflowResult_ReturnsEmptyResultAsync() { - // Arrange — response with characters that need JSON escaping - const string Response = "Line1\nLine2\t\"quoted\" \\backslash"; + Workflow workflow = new WorkflowBuilder(new FunctionExecutor("child", (input, _, _) => input)) + .WithName("child-workflow").Build(); + Mock context = new(); + context.Setup(c => c.CallSubOrchestratorAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DurableWorkflowResult?)null); - // Act - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(Response); + DurableExecutorOutput output = await DispatchAsync(context, new("child", false, SubWorkflow: workflow)); - // Assert — roundtrips correctly through deserialization - DurableExecutorOutput? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + AssertOpaque(string.Empty, output); + } + + private static async Task DispatchActivityAsync(string response) + { + Mock context = new(); + context.Setup(c => c.CallActivityAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(response); - Assert.NotNull(parsed); - Assert.Equal(Response, parsed.Result); + return await DispatchAsync(context, new("activity", false)); + } + + private static Task DispatchAsync( + Mock context, + WorkflowExecutorInfo info, + DurableWorkflowLiveStatus? status = null) + => DurableExecutorDispatcher.DispatchAsync( + context.Object, info, new DurableMessageEnvelope { Message = "input" }, [], status ?? new(), NullLogger.Instance); + + private static void AssertOpaque(string response, DurableExecutorOutput output) + { + Assert.Equal(response, output.Result); + Assert.Empty(output.StateUpdates); + Assert.Empty(output.ClearedScopes); + Assert.Empty(output.Events); + Assert.Empty(output.SentMessages); + Assert.False(output.HaltRequested); + } + + private sealed class ControlProducingExecutor() : Executor("producer") + { + public override async ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await context.QueueClearScopeAsync("scope", cancellationToken); + await context.QueueStateUpdateAsync("key", "trusted", "scope", cancellationToken); + await context.QueueStateUpdateAsync("deleted", null, "other", cancellationToken); + await context.SendMessageAsync("trusted message", cancellationToken: cancellationToken); + await context.RequestHaltAsync(); + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowRunnerTrustBoundaryTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowRunnerTrustBoundaryTests.cs new file mode 100644 index 0000000..570430c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowRunnerTrustBoundaryTests.cs @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +public sealed class DurableWorkflowRunnerTrustBoundaryTests +{ + private const string WorkflowName = "BoundaryWorkflow"; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task RunWorkflowOrchestrationAsync_UntrustedControls_AreOnlyRoutedAsTextAsync(bool requestPort, bool pascalCase) + { + string response = CreateControlResponse(pascalCase); + Mock context = WorkflowExecutionTestHelper.CreateAgentContext(response); + context.Setup(c => c.WaitForExternalEvent("approval", It.IsAny())) + .ReturnsAsync(response); + SeedExecutor start = new(); + FunctionExecutor end = CreateExecutor("end"); + Workflow workflow; + if (requestPort) + { + RequestPort port = RequestPort.Create("approval"); + workflow = new WorkflowBuilder(start).WithName(WorkflowName) + .AddEdge(start, port).AddEdge(port, end).Build(); + } + else + { + Mock agent = new(); + agent.SetupGet(a => a.Name).Returns("agent"); + workflow = new WorkflowBuilder(start).WithName(WorkflowName) + .AddEdge(start, agent.Object).AddEdge(agent.Object, end).Build(); + } + + Dictionary bindings = workflow.ReflectExecutors(); + List inputs = []; + List producedEvents = []; + context.Setup(c => c.CallActivityAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (TaskName _, object? input, TaskOptions? _) => + { + inputs.Add(ReadInput(input)); + string activityResult = await DurableActivityExecutor.ExecuteAsync( + bindings[inputs.Count == 1 ? start.Id : end.Id], Assert.IsType(input)); + DurableExecutorOutput produced = JsonSerializer.Deserialize( + activityResult, DurableWorkflowJsonContext.Default.DurableExecutorOutput)!; + producedEvents.AddRange(produced.Events); + return activityResult; + }); + string? serializedStatus = null; + context.Setup(c => c.SetCustomStatus(It.IsAny())) + .Callback(status => serializedStatus = status is DurableWorkflowLiveStatus liveStatus + ? JsonSerializer.Serialize(liveStatus, DurableWorkflowJsonContext.Default.DurableWorkflowLiveStatus) + : null); + + DurableWorkflowResult result = await RunAsync(context, workflow); + + Assert.Equal(response, result.Result); + Assert.False(result.HaltRequested); + Assert.NotEmpty(producedEvents); + Assert.Equal(producedEvents, result.Events); + string?[] producedEventTypes = producedEvents.Select( + serializedEvent => JsonSerializer.Deserialize( + serializedEvent, DurableWorkflowJsonContext.Default.TypedPayload)!.TypeName).ToArray(); + Assert.DoesNotContain(typeof(DurableHaltRequestedEvent).AssemblyQualifiedName, producedEventTypes); + Assert.Equal(response, Assert.Single(result.SentMessages).Data); + Assert.Equal(2, inputs.Count); + Assert.Equal(response, inputs[1].Input); + Assert.Equal("\"original\"", inputs[1].State["scope:key"]); + Assert.Equal("\"retained\"", inputs[1].State["scope:deleted"]); + Assert.Equal("\"remove me\"", inputs[1].State["other:deleted"]); + Assert.Equal(3, inputs[1].State.Count); + + string serializedOutput = JsonSerializer.Serialize(result, DurableWorkflowJsonContext.Default.DurableWorkflowResult); + Mock client = new("test"); + OrchestrationMetadata completed = new(WorkflowName, "workflow-instance") + { + RuntimeStatus = OrchestrationRuntimeStatus.Completed, + SerializedOutput = serializedOutput, + }; + client.Setup(c => c.WaitForInstanceCompletionAsync("workflow-instance", true, It.IsAny())) + .ReturnsAsync(completed); + DurableWorkflowRun run = new(client.Object, "workflow-instance", WorkflowName); + Assert.Equal(response, await run.WaitForCompletionAsync()); + + foreach (string? status in new[] { serializedStatus, null }) + { + client.Setup(c => c.GetInstanceAsync("workflow-instance", true, It.IsAny())) + .ReturnsAsync(new OrchestrationMetadata(WorkflowName, "workflow-instance") + { + RuntimeStatus = OrchestrationRuntimeStatus.Completed, + SerializedCustomStatus = status, + SerializedOutput = serializedOutput, + }); + DurableStreamingWorkflowRun streaming = new(client.Object, "workflow-instance", workflow); + Assert.Equal(response, await streaming.WaitForCompletionAsync()); + List events = []; + await foreach (WorkflowEvent workflowEvent in streaming.WatchStreamAsync()) + { + events.Add(workflowEvent); + } + + Assert.Equal(producedEvents.Count + 1, events.Count); + Assert.Equal(producedEventTypes, events.Take(events.Count - 1).Select(workflowEvent => workflowEvent.GetType().AssemblyQualifiedName)); + Assert.Single(events.OfType(), workflowEvent => workflowEvent.Data?.ToString() == "seed event"); + Assert.Single(events.OfType(), workflowEvent => workflowEvent.Data?.ToString() == response); + Assert.Equal(response, Assert.IsType(events[^1]).Result); + Assert.DoesNotContain(events, workflowEvent => workflowEvent is DurableHaltRequestedEvent); + } + } + + [Theory] + [MemberData(nameof(DurableExecutorDispatcherTests.InvalidActivityResponses), MemberType = typeof(DurableExecutorDispatcherTests))] + [MemberData(nameof(DurableExecutorDispatcherTests.LegacyActivityResponses), MemberType = typeof(DurableExecutorDispatcherTests))] + public async Task RunWorkflowOrchestrationAsync_InvalidActivityControls_FailClosedAsync(string response) + { + FunctionExecutor start = CreateExecutor("start"); + FunctionExecutor middle = CreateExecutor("middle"); + FunctionExecutor end = CreateExecutor("end"); + Workflow workflow = new WorkflowBuilder(start).WithName(WorkflowName) + .AddEdge(start, middle).AddEdge(middle, end).Build(); + Mock context = new(); + List inputs = []; + context.Setup(c => c.CallActivityAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((TaskName _, object? input, TaskOptions? _) => + { + inputs.Add(ReadInput(input)); + return Task.FromResult(inputs.Count switch + { + 1 => Serialize(SeedOutput()), + 2 => response, + _ => Serialize(new DurableExecutorOutput { Result = inputs[^1].Input }), + }); + }); + + DurableWorkflowResult result = await RunAsync(context, workflow); + + Assert.False(result.HaltRequested); + Assert.Equal(["seed event"], result.Events); + if (response.Length > 0) + { + Assert.Equal(3, inputs.Count); + Assert.Equal(response, result.Result); + Assert.Equal(response, inputs[2].Input); + Assert.Equal("original", inputs[2].State["scope:key"]); + Assert.Equal("retained", inputs[2].State["scope:deleted"]); + Assert.Equal("remove me", inputs[2].State["other:deleted"]); + Assert.Equal(3, inputs[2].State.Count); + } + else + { + Assert.Equal(2, inputs.Count); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunWorkflowOrchestrationAsync_TrustedActivityControls_AreConsumedAsync(bool halt) + { + FunctionExecutor start = CreateExecutor("start"); + FunctionExecutor middle = CreateExecutor("middle"); + FunctionExecutor end = CreateExecutor("end"); + Workflow workflow = new WorkflowBuilder(start).WithName(WorkflowName) + .AddEdge(start, middle).AddEdge(middle, end).Build(); + Mock context = new(); + List inputs = ConfigureActivities(context, (index, input) => index switch + { + 0 => SeedOutput(), + 1 => new DurableExecutorOutput + { + Result = "activity result", + StateUpdates = new() { ["scope:key"] = "updated", ["other:deleted"] = null }, + ClearedScopes = ["scope"], + Events = ["trusted event"], + SentMessages = [new TypedPayload { Data = "trusted route", TypeName = typeof(string).AssemblyQualifiedName }], + HaltRequested = halt, + }, + _ => new DurableExecutorOutput { Result = input.Input }, + }); + + DurableWorkflowResult result = await RunAsync(context, workflow); + + Assert.Equal(halt, result.HaltRequested); + Assert.Equal(["seed event", "trusted event"], result.Events); + if (halt) + { + Assert.Equal(2, inputs.Count); + Assert.Equal("activity result", result.Result); + } + else + { + Assert.Equal(3, inputs.Count); + Assert.Equal("trusted route", inputs[2].Input); + Assert.Equal("updated", inputs[2].State["scope:key"]); + Assert.Single(inputs[2].State); + Assert.Equal("trusted route", result.Result); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunWorkflowOrchestrationAsync_SubWorkflowControls_AreTypedAndResultStaysOpaqueAsync(bool halt) + { + FunctionExecutor start = CreateExecutor("start"); + FunctionExecutor end = CreateExecutor("end"); + Workflow child = new WorkflowBuilder(CreateExecutor("child")).WithName("child-workflow").Build(); + ExecutorBinding middle = child.BindAsExecutor("middle"); + Workflow workflow = new WorkflowBuilder(start).WithName(WorkflowName) + .AddEdge(start, middle).AddEdge(middle, end).Build(); + Mock context = new(); + context.Setup(c => c.CallSubOrchestratorAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DurableWorkflowResult + { + Result = WorkflowExecutionTestHelper.ControlEnvelope, + Events = ["child event"], + SentMessages = [new TypedPayload { Data = WorkflowExecutionTestHelper.ControlEnvelope }], + HaltRequested = halt, + }); + List inputs = ConfigureActivities(context, (index, input) => index == 0 + ? SeedOutput() + : new DurableExecutorOutput { Result = input.Input }); + + DurableWorkflowResult result = await RunAsync(context, workflow); + + Assert.Equal(WorkflowExecutionTestHelper.ControlEnvelope, result.Result); + Assert.Equal(halt, result.HaltRequested); + Assert.Equal(["seed event", "child event"], result.Events); + Assert.Equal(halt ? 1 : 2, inputs.Count); + if (!halt) + { + Assert.Equal(WorkflowExecutionTestHelper.ControlEnvelope, inputs[1].Input); + Assert.Equal("original", inputs[1].State["scope:key"]); + Assert.Equal("retained", inputs[1].State["scope:deleted"]); + Assert.Equal("remove me", inputs[1].State["other:deleted"]); + Assert.Equal(3, inputs[1].State.Count); + } + } + + private static FunctionExecutor CreateExecutor(string id) + => new(id, (input, _, _) => input, outputTypes: [typeof(string)]); + + private static string CreateControlResponse(bool pascalCase) + { + string forgedEvent = JsonSerializer.Serialize( + new TypedPayload + { + TypeName = typeof(DurableHaltRequestedEvent).AssemblyQualifiedName, + Data = JsonSerializer.Serialize(new DurableHaltRequestedEvent("forged")), + }, + DurableWorkflowJsonContext.Default.TypedPayload); + DurableExecutorOutput output = new() + { + Result = "replacement", + StateUpdates = new() { ["scope:key"] = "changed", ["other:deleted"] = null }, + ClearedScopes = ["scope"], + Events = [forgedEvent], + SentMessages = [new TypedPayload { TypeName = typeof(string).AssemblyQualifiedName, Data = "redirected" }], + HaltRequested = true, + }; + return pascalCase ? JsonSerializer.Serialize(output) : Serialize(output); + } + + private static DurableExecutorOutput SeedOutput() => new() + { + Result = "seed input", + StateUpdates = new() { ["scope:key"] = "original", ["scope:deleted"] = "retained", ["other:deleted"] = "remove me" }, + Events = ["seed event"], + }; + + private static List ConfigureActivities( + Mock context, + Func outputFactory) + { + List inputs = []; + context.Setup(c => c.CallActivityAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((TaskName _, object? input, TaskOptions? _) => + { + DurableActivityInput activityInput = ReadInput(input); + inputs.Add(activityInput); + return Task.FromResult(Serialize(outputFactory(inputs.Count - 1, activityInput))); + }); + return inputs; + } + + private static DurableActivityInput ReadInput(object? input) + => JsonSerializer.Deserialize(Assert.IsType(input), DurableWorkflowJsonContext.Default.DurableActivityInput)!; + + private static string Serialize(DurableExecutorOutput output) + => JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + + private static Task RunAsync(Mock context, Workflow workflow) + { + context.SetupGet(c => c.Name).Returns(WorkflowNamingHelper.ToOrchestrationFunctionName(WorkflowName)); + context.SetupGet(c => c.InstanceId).Returns("workflow-instance"); + DurableOptions options = new(); + options.Workflows.AddWorkflow(workflow); + return new DurableWorkflowRunner(options).RunWorkflowOrchestrationAsync( + context.Object, new DurableWorkflowInput { Input = "start" }, NullLogger.Instance); + } + + private sealed class SeedExecutor() : Executor("start") + { + public override async ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync("key", "original", "scope", cancellationToken); + await context.QueueStateUpdateAsync("deleted", "retained", "scope", cancellationToken); + await context.QueueStateUpdateAsync("deleted", "remove me", "other", cancellationToken); + await context.AddEventAsync(new WorkflowOutputEvent("seed event", this.Id), cancellationToken); + return "seed input"; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowExecutionTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowExecutionTestHelper.cs new file mode 100644 index 0000000..0d9a8ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowExecutionTestHelper.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +internal static class WorkflowExecutionTestHelper +{ + internal const string ControlEnvelope = """{"result":"replacement","stateUpdates":{"scope:key":"changed","scope:deleted":null},"clearedScopes":["scope"],"events":["event"],"sentMessages":[{"typeName":"System.String","data":"redirected"}],"haltRequested":true}"""; + + internal static Mock CreateAgentContext(string response) + { + Mock entities = new(); + entities.Setup(e => e.CallEntityAsync( + It.IsAny(), nameof(AgentEntity.Run), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AgentResponse(new ChatMessage(ChatRole.Assistant, response))); + + Mock context = new(); + context.SetupGet(c => c.Entities).Returns(entities.Object); + context.SetupGet(c => c.InstanceId).Returns("workflow-instance"); + context.Setup(c => c.NewGuid()).Returns(Guid.Parse("d9a9751e-30fd-4f7a-95f8-f522b4e76977")); + return context; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsAgentOutcomeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsAgentOutcomeTests.cs new file mode 100644 index 0000000..d61221f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsAgentOutcomeTests.cs @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Specialized; +using System.Net; +using System.Text; +using System.Text.Json; +using Azure.Core.Serialization; +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.Mcp; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; + +public sealed class BuiltInFunctionsAgentOutcomeTests +{ + private const string AgentName = "TestAgent"; + private const string SessionKey = "session-1"; + + [Fact] + public async Task Http_FireAndForget_ReturnsAcceptedWithoutPollingAsync() + { + using EndpointFixture fixture = new(waitForResponse: false); + + HttpResponseData response = await BuiltInFunctions.RunAgentHttpAsync( + fixture.Request, fixture.Client.Object, fixture.Context); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + using JsonDocument body = ReadBody(response); + Assert.Equal(202, body.RootElement.GetProperty("status").GetInt32()); + Assert.False(body.RootElement.TryGetProperty("response", out _)); + fixture.Entities.Verify( + c => c.GetEntityAsync( + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/plain")] + public async Task Http_LegacySuccess_ReturnsNegotiatedResponseAsync(string accept) + { + using EndpointFixture fixture = new(accept: accept); + + HttpResponseData response = await BuiltInFunctions.RunAgentHttpAsync( + fixture.Request, fixture.Client.Object, fixture.Context); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(SessionKey, Assert.Single(response.Headers.GetValues("x-ms-session-id"))); + if (accept == "application/json") + { + using JsonDocument body = ReadBody(response); + Assert.Equal(200, body.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("original result", body.RootElement.GetProperty("response") + .GetProperty("messages")[0].GetProperty("contents")[0].GetProperty("text").GetString()); + } + else + { + response.Body.Position = 0; + using StreamReader reader = new(response.Body, leaveOpen: true); + Assert.Equal("original result", await reader.ReadToEndAsync()); + } + } + + [Theory] + [InlineData(null)] + [InlineData("text")] + [InlineData("json")] + public async Task Mcp_Success_PreservesLegacyTextOrExplicitJsonAsync(string? format) + { + using EndpointFixture fixture = new(); + + string? response = await BuiltInFunctions.RunMcpToolAsync( + CreateToolContext(format), fixture.Client.Object, fixture.Context); + + if (format == "json") + { + using JsonDocument body = JsonDocument.Parse(Assert.IsType(response)); + Assert.Equal(SessionKey, body.RootElement.GetProperty("session_id").GetString()); + Assert.Equal("original result", body.RootElement.GetProperty("response") + .GetProperty("messages")[0].GetProperty("contents")[0].GetProperty("text").GetString()); + } + else + { + Assert.Equal("original result", response); + } + } + + [Theory] + [InlineData("yaml")] + [InlineData("")] + [InlineData(3)] + public async Task Mcp_InvalidFormat_DoesNotDispatchAsync(object format) + { + using EndpointFixture fixture = new(); + ToolInvocationContext invocation = CreateToolContext(); + invocation.Arguments![BuiltInFunctionsTestResponseFormat] = format; + + await Assert.ThrowsAsync(() => BuiltInFunctions.RunMcpToolAsync( + invocation, fixture.Client.Object, fixture.Context)); + + fixture.Entities.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Mcp_Cancellation_ReachesDurableClientAsync() + { + using CancellationTokenSource cancellation = new(); + using EndpointFixture fixture = new(cancellationToken: cancellation.Token); + fixture.Entities + .Setup(c => c.SignalEntityAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), cancellation.Token)) + .ThrowsAsync(new OperationCanceledException(cancellation.Token)); + + await Assert.ThrowsAsync(() => BuiltInFunctions.RunMcpToolAsync( + CreateToolContext(), fixture.Client.Object, fixture.Context)); + } + + [Theory] + [InlineData("succeeded", "application/json")] + [InlineData("failed", "application/json")] + [InlineData("succeeded", "text/plain")] + [InlineData("failed", "text/plain")] + public async Task Http_Unavailable_RetainsCompletionOutcomeAsync(string outcome, string accept) + { + using EndpointFixture fixture = new( + accept: accept, stateFactory: correlation => CreateMailboxState(correlation, outcome, available: false)); + + HttpResponseData response = await BuiltInFunctions.RunAgentHttpAsync( + fixture.Request, fixture.Client.Object, fixture.Context); + + Assert.Equal(HttpStatusCode.Gone, response.StatusCode); + Assert.Equal(outcome, Assert.Single(response.Headers.GetValues("x-ms-agent-completion-outcome"))); + if (accept == "application/json") + { + using JsonDocument body = ReadBody(response); + Assert.Equal("completedResultUnavailable", body.RootElement.GetProperty("outcome").GetString()); + Assert.Equal(outcome, body.RootElement.GetProperty("completion_outcome").GetString()); + Assert.False(body.RootElement.TryGetProperty("response", out _)); + } + } + + [Fact] + public async Task Http_CommittedFailure_PreservesErrorDetailsAsync() + { + using EndpointFixture fixture = new( + stateFactory: correlation => CreateMailboxState(correlation, "failed", available: true)); + + HttpResponseData response = await BuiltInFunctions.RunAgentHttpAsync( + fixture.Request, fixture.Client.Object, fixture.Context); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + using JsonDocument body = ReadBody(response); + Assert.Equal("failed", body.RootElement.GetProperty("outcome").GetString()); + JsonElement error = body.RootElement.GetProperty("error"); + Assert.Equal("committedFailure", error.GetProperty("code").GetString()); + Assert.True(error.GetProperty("details").GetProperty("retained").GetBoolean()); + Assert.False(body.RootElement.TryGetProperty("response", out _)); + } + + [Theory] + [InlineData("text", "succeeded")] + [InlineData("json", "succeeded")] + [InlineData("text", "failed")] + [InlineData("json", "failed")] + public async Task Mcp_Unavailable_ThrowsInsteadOfReturningSuccessfulTextAsync(string format, string outcome) + { + using EndpointFixture fixture = new( + stateFactory: correlation => CreateMailboxState(correlation, outcome, available: false)); + + DurableAgentResultUnavailableException exception = + await Assert.ThrowsAsync(() => BuiltInFunctions.RunMcpToolAsync( + CreateToolContext(format), fixture.Client.Object, fixture.Context)); + + Assert.Equal(outcome, exception.Outcome); + } + + [Theory] + [InlineData("text")] + [InlineData("json")] + public async Task Mcp_CommittedFailure_ThrowsWithOriginalDetailsAsync(string format) + { + using EndpointFixture fixture = new( + stateFactory: correlation => CreateMailboxState(correlation, "failed", available: true)); + + DurableAgentTerminalException exception = + await Assert.ThrowsAsync(() => BuiltInFunctions.RunMcpToolAsync( + CreateToolContext(format), fixture.Client.Object, fixture.Context)); + + Assert.Equal("committedFailure", exception.Code); + Assert.True(exception.Details!.Value.GetProperty("retained").GetBoolean()); + } + + [Fact] + public async Task Http_TransientReadFailure_IsNotConvertedToTerminalSuccessAsync() + { + using EndpointFixture fixture = new(); + fixture.Entities + .Setup(c => c.GetEntityAsync( + It.IsAny(), true, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Storage temporarily unavailable.")); + + await Assert.ThrowsAsync(() => BuiltInFunctions.RunAgentHttpAsync( + fixture.Request, fixture.Client.Object, fixture.Context)); + } + + [Fact] + public async Task Mcp_PendingUntilCancellation_DoesNotReturnEmptySuccessAsync() + { + using CancellationTokenSource cancellation = new(); + using EndpointFixture fixture = new(cancellationToken: cancellation.Token); + fixture.Entities + .Setup(c => c.GetEntityAsync( + It.IsAny(), true, It.IsAny())) + .Callback(cancellation.Cancel) + .ReturnsAsync((EntityMetadata?)null); + + await Assert.ThrowsAnyAsync(() => BuiltInFunctions.RunMcpToolAsync( + CreateToolContext("json"), fixture.Client.Object, fixture.Context)); + } + + [Theory] + [InlineData(false, null)] + [InlineData(false, "null")] + [InlineData(false, "false")] + [InlineData(false, "0")] + [InlineData(false, "\"\"")] + [InlineData(false, "{\"answer\":42}")] + [InlineData(true, null)] + [InlineData(true, "null")] + [InlineData(true, "false")] + [InlineData(true, "0")] + [InlineData(true, "\"\"")] + [InlineData(true, "{\"answer\":42}")] + public async Task JsonSuccess_PreservesCanonicalMetadataAndValueAsync(bool mcp, string? value) + { + using EndpointFixture fixture = new( + stateFactory: correlation => CreateSuccessfulMailboxState(correlation, value)); + using JsonDocument body = mcp + ? JsonDocument.Parse(Assert.IsType(await BuiltInFunctions.RunMcpToolAsync( + CreateToolContext("json"), fixture.Client.Object, fixture.Context))) + : ReadBody(await BuiltInFunctions.RunAgentHttpAsync( + fixture.Request, fixture.Client.Object, fixture.Context)); + + Assert.True(body.RootElement.TryGetProperty("result", out JsonElement result), + "The JSON response must include the lossless terminal result, not only its AgentResponse projection."); + Assert.Equal("response-1", result.GetProperty("responseId").GetString()); + Assert.Equal("agent-1", result.GetProperty("agentId").GetString()); + Assert.Equal("stop", result.GetProperty("finishReason").GetString()); + Assert.Equal("AQID", result.GetProperty("continuationToken").GetString()); + Assert.Equal(8, result.GetProperty("usage").GetProperty("totalTokenCount").GetInt64()); + Assert.True(result.GetProperty("futureResponseField").GetProperty("retained").GetBoolean()); + Assert.Equal("west", result.GetProperty("extensionData").GetProperty("region").GetString()); + Assert.Equal(value is not null, result.TryGetProperty("value", out JsonElement actualValue)); + if (value is not null) + { + Assert.Equal(value, actualValue.GetRawText()); + } + + Assert.True(body.RootElement.TryGetProperty("response", out _)); + } + + private const string BuiltInFunctionsTestResponseFormat = "responseFormat"; + + private static DurableAgentState CreateSuccessfulMailboxState(string correlation, string? value) + { + string valueField = value is null ? string.Empty : $",\"value\":{value}"; + return JsonSerializer.Deserialize( + $$""" + { + "schemaVersion":"2.0.0", + "data":{ + "conversationHistory":[], + "terminalResults":{ + "{{correlation}}":{ + "correlationId":"{{correlation}}","outcome":"succeeded","completedAt":"2026-09-11T12:00:00Z", + "response":{ + "messages":[{"role":"assistant","contents":[{"$type":"text","text":"full result"}]}], + "responseId":"response-1","agentId":"agent-1","finishReason":"stop","continuationToken":"AQID", + "createdAt":"2026-09-11T12:00:00Z", + "usage":{"inputTokenCount":5,"outputTokenCount":3,"totalTokenCount":8}, + "extensionData":{"region":"west"},"futureResponseField":{"retained":true} + {{valueField}} + } + } + }, + "completionReceipts":{ + "{{correlation}}":{ + "correlationId":"{{correlation}}","outcome":"succeeded", + "completedAt":"2026-09-11T12:00:00Z","resultState":"available" + } + } + } + } + """)!; + } + + private static DurableAgentState CreateMailboxState(string correlation, string outcome, bool available) + { + string result = available + ? $$""" + "{{correlation}}":{ + "correlationId":"{{correlation}}","outcome":"failed","completedAt":"2026-09-11T12:00:00Z", + "response":{"messages":[]}, + "error":{"code":"committedFailure","message":"A durable failure.","details":{"retained":true} } + } + """ + : string.Empty; + string unavailableTimestamp = available + ? string.Empty + : ""","resultUnavailableAt":"2026-09-11T12:00:01Z" """; + return JsonSerializer.Deserialize( + $$""" + { + "schemaVersion":"2.0.0", + "data":{ + "conversationHistory":[], + "terminalResults":{ {{result}} }, + "completionReceipts":{ + "{{correlation}}":{ + "correlationId":"{{correlation}}","outcome":"{{outcome}}", + "completedAt":"2026-09-11T12:00:00Z","resultState":"{{(available ? "available" : "unavailable")}}"{{unavailableTimestamp}} + } + } + } + } + """)!; + } + + private static ToolInvocationContext CreateToolContext(string? format = null) + { + Dictionary arguments = new() + { + ["query"] = "hello", + ["sessionId"] = SessionKey, + }; + if (format is not null) + { + arguments[BuiltInFunctionsTestResponseFormat] = format; + } + + return new ToolInvocationContext { Name = AgentName, Arguments = arguments }; + } + + private static JsonDocument ReadBody(HttpResponseData response) + { + response.Body.Position = 0; + return JsonDocument.Parse(response.Body); + } + + private sealed class EndpointFixture : IDisposable + { + private readonly ServiceProvider _services; + private readonly MemoryStream _requestBody = new(Encoding.UTF8.GetBytes("hello")); + private readonly MemoryStream _responseBody = new(); + private string? _correlationId; + + public EndpointFixture( + bool waitForResponse = true, + string accept = "application/json", + Func? stateFactory = null, + CancellationToken cancellationToken = default) + { + ServiceCollection services = new(); + services.AddLogging(); + services.ConfigureDurableAgents(agents => + agents.AddAIAgent(new TestAgent(AgentName, "An agent used for endpoint tests."))); + services.Configure(options => + options.Serializer = new JsonObjectSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web))); + this._services = services.BuildServiceProvider(); + + Mock definition = new(); + definition.SetupGet(d => d.Name).Returns("http-" + AgentName); + Mock context = new(); + context.SetupGet(c => c.InstanceServices).Returns(this._services); + context.SetupGet(c => c.FunctionDefinition).Returns(definition.Object); + context.SetupGet(c => c.InvocationId).Returns("invocation-1"); + context.SetupGet(c => c.CancellationToken).Returns(cancellationToken); + this.Context = context.Object; + + HttpHeadersCollection headers = new(); + headers.Add("Accept", accept); + Mock response = new(this.Context); + response.SetupProperty(r => r.StatusCode, HttpStatusCode.OK); + response.SetupProperty(r => r.Body, this._responseBody); + response.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection()); + Mock request = new(this.Context); + request.SetupGet(r => r.Headers).Returns(headers); + request.SetupGet(r => r.Body).Returns(this._requestBody); + request.SetupGet(r => r.Url).Returns(new Uri( + $"https://localhost/api/agents/{AgentName}/run?session_id={SessionKey}&wait_for_response={waitForResponse}")); + request.SetupGet(r => r.Query).Returns(new NameValueCollection + { + ["session_id"] = SessionKey, + ["wait_for_response"] = waitForResponse.ToString(), + }); + request.Setup(r => r.CreateResponse()).Returns(response.Object); + this.Request = request.Object; + + this.Entities = new Mock("test") { CallBase = true }; + this.Entities + .Setup(c => c.SignalEntityAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, _, input, _, _) => this._correlationId = Assert.IsType(input).CorrelationId) + .Returns(Task.CompletedTask); + this.Entities + .Setup(c => c.GetEntityAsync( + It.IsAny(), true, It.IsAny())) + .Returns((id, _, _) => + Task.FromResult?>( + new(id, (stateFactory ?? CreateLegacyState)(this._correlationId!)))); + this.Client = new Mock("test"); + this.Client.SetupGet(c => c.Entities).Returns(this.Entities.Object); + } + + public HttpRequestData Request { get; } + + public FunctionContext Context { get; } + + public Mock Client { get; } + + public Mock Entities { get; } + + public void Dispose() + { + this._services.Dispose(); + this._requestBody.Dispose(); + this._responseBody.Dispose(); + } + + private static DurableAgentState CreateLegacyState(string correlationId) => + JsonSerializer.Deserialize( + $$""" + { + "schemaVersion":"1.2.0", + "data":{"conversationHistory":[{ + "$type":"response","correlationId":"{{correlationId}}", + "createdAt":"2026-09-11T12:00:00Z", + "messages":[{"role":"assistant","contents":[{"$type":"text","text":"original result"}]}] + }] } + } + """)!; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsSessionIdAliasTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsSessionIdAliasTests.cs index 211bc9c..3eb2f2a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsSessionIdAliasTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsSessionIdAliasTests.cs @@ -107,6 +107,31 @@ public void AgentRunAcceptedResponse_EmitsOnlySessionId() Assert.False(document.RootElement.TryGetProperty("thread_id", out _)); } + [Fact] + public void AgentRunFailureResponse_PreservesOutcomeAndErrorMetadata() + { + BuiltInFunctions.AgentRunFailureResponse response = new( + 410, + "session-3", + "completedResultUnavailable", + new BuiltInFunctions.AgentRunError( + "resultUnavailable", + "The result payload is unavailable.", + JsonSerializer.SerializeToElement(new { expired = true }))); + + using JsonDocument document = + JsonDocument.Parse(JsonSerializer.Serialize(response)); + + Assert.Equal(410, document.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("session-3", document.RootElement.GetProperty("session_id").GetString()); + Assert.Equal( + "completedResultUnavailable", + document.RootElement.GetProperty("outcome").GetString()); + JsonElement error = document.RootElement.GetProperty("error"); + Assert.Equal("resultUnavailable", error.GetProperty("code").GetString()); + Assert.True(error.GetProperty("details").GetProperty("expired").GetBoolean()); + } + [Theory] // bodySessionId, bodyThreadId, querySessionId, queryThreadId, expected [InlineData(null, null, null, null, null)] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs index 3d4cfa7..69b6609 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs @@ -141,7 +141,7 @@ static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool Assert.Single(metadataList, m => m.Name == $"mcptool-{agentName}") as DefaultFunctionMetadata; Assert.NotNull(mcpToolMeta); Assert.NotNull(mcpToolMeta.RawBindings); - Assert.Equal(4, mcpToolMeta.RawBindings.Count); + Assert.Equal(5, mcpToolMeta.RawBindings.Count); Assert.Contains("mcpToolTrigger", mcpToolMeta.RawBindings[0]); // Only the canonical "sessionId" property is advertised; the deprecated "threadId" @@ -155,7 +155,7 @@ static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool string[] advertisedNames = [.. toolProperties.RootElement.EnumerateArray() .Select(p => p.GetProperty("propertyName").GetString()!)]; - Assert.Equal(["query", "sessionId"], advertisedNames); + Assert.Equal(["query", "sessionId", "responseFormat"], advertisedNames); Assert.Single(mcpToolMeta.RawBindings, b => b.Contains("\"propertyName\":\"sessionId\"")); Assert.DoesNotContain(mcpToolMeta.RawBindings, b => b.Contains("threadId")); diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..db94d6e --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,277 @@ +# Proposed durable agent state 2.0 contract + +**Proposal for joint Python, .NET, and Durable Task review; not runtime activation.** +This change contains only a JSON Schema, synthetic fixtures, and documentation. +Neither Python nor .NET is authorized to emit `2.0.0` by this proposal or by a +successful schema validation. Merge of this contract proposal requires agreement +on compatible-reader/consumer semantics and the rollout floor; emitting 2.0 +requires a separately reviewed implementation and an enforced deployment gate. + +The motivation is to separate execution/delivery completion from conversation +history that may be compacted or evicted. Preserving unknown JSON alone cannot +prevent duplicate execution if a reader still uses transcript responses to +decide whether a request completed. [ADR discussion #88](https://github.com/microsoft/agent-framework-durable-extension/pull/88) +provides related compaction/retention context, not a mandate for this exact schema. + +## Version and reader policy + +The [Draft 2020-12 schema](durable-agent-entity-state.json) describes exact review +snapshots `1.0.0`, `1.1.0`, proposed `1.2.0`, and proposed `2.0.0`; it is not a +list of versions supported by today's runtimes. The proposed 1.2 fields are +included for compatibility discussion, not claimed as released Python output. +Legacy transcript entries retain the existing permissive entry shape. The +three mailbox/binding fields are forbidden in 1.x, even if empty. Version 2.0 +requires `terminalResults`, `completionReceipts`, and `conversationHistory` +(which can be empty). `historyBinding` is an optional runtime extension/profile, +not a shared requirement for session-fixed effective ownership. + +Historical `1.0.0`, `1.1.0`, and `1.2.0` message/content validation is unchanged +from the pre-widening proposal at `efed11f7786332d8bb0447ddbfc1727ddd7de09b`: +roles exclude `developer`, function arguments are objects when present, and +URI content requires `mediaType`. The root version selects historical +`conversationEntry` definitions or the v2 definitions. Only v2 transcript entries +and terminal responses use the expanded lossless shapes. This is not a +historical-contract correction, and no persisted legacy bytes are rewritten. + +Major 2 is proposed because the authority for completion and replay changes, +not merely because new optional properties appear. **Is a major version the +right mechanism, or can maintainers enforce an equally safe same-major rollout +gate?** This schema intentionally rejects unlisted versions, including future +2.x versions; accepting them must be a deliberate semantic compatibility +decision, not just a numeric SemVer comparison. + +At upstream main `62afdbac03f0a81b0917abe6328203b706a2f294`, +Python's `DurableAgentState.from_dict` checks for a version's presence but does +not gate its value, and `DurableAgentStateData.from_dict` reads the transcript +without retaining unknown data-level fields. This is a compatibility gap to +resolve jointly, not a criticism or a claim that Python already supports 2.0. +The revised C# preview instead rejects unsupported versions; its acceptance +rules are not a shared runtime guarantee. A new major number alone does not +protect deployments whose existing readers do not enforce it. + +## Proposed wire concepts and semantic invariants + +| Field | Proposed meaning | +| --- | --- | +| `terminalResults[correlationId]` | Immutable terminal response/error envelope, detached from transcript retention. | +| `completionReceipts[correlationId]` | Independent completion evidence, retained after payload expiry and transcript pruning. | +| Receipt `resultState` | `available` with a matching payload, or `unavailable` with a removal timestamp. | +| `historyBinding` | Optional, separately versioned runtime extension/profile; preserved without imposing shared effective-owner semantics. | +| `conversationHistory` | Evictable transcript, never the authoritative completion index in 2.0. | + +The schema validates local shape, not all cross-object or temporal invariants. +Any future implementation must additionally enforce: + +- Keys equal the embedded `correlationId`, compared exactly and case-sensitively + without normalization, within one durable entity/session generation. + When present on a v2 request, response, or error response, the transcript + `correlationId` uses the same `identifier` constraints as the result maps. + Legacy entry handling is unchanged; compaction still has no correlation. + Request identities must not be reused for different work in that generation. + Every terminal result has exactly one matching receipt. Outcome, completion + instant, and expiry instant (including absence) agree between them. +- Terminal result contents and receipt identity/outcome/completion/expiry are + immutable. At the entity operation boundary, the result and receipt must + commit atomically with that operation's session/continuation, ingestion, + entity-local transcript, TTL, binding, and other local control-state changes. + They must not commit independently of the corresponding local state. + External provider writes and tool effects are outside this local transaction, + as discussed in the [ADR](https://github.com/microsoft/agent-framework-durable-extension/pull/88). + Availability can transition only from `available` to `unavailable`, + atomically removing the result and recording `resultUnavailableAt`; it never + reopens execution or changes a success into a failure. +- An `available` receipt requires a result; an `unavailable` receipt forbids one. + No receipt means only **no recorded terminal completion**. It means pending + only for a request independently known to have been accepted; unknown request + identities are not automatically pending. This proposal adds no request + admission registry and makes no exactly-once claim for external side effects. +- `resultExpiresAt`, when present, is no earlier than `completedAt`. At or after + that instant a poller must report completed-but-result-unavailable even if a + lazy cleanup has not yet removed a stored `available` payload. It must not + deliver the expired payload, report pending, or rerun the request. + `resultUnavailableAt` is no earlier than completion; for a time-expired + payload it is no earlier than expiry. `unavailable` can also represent an + agreed explicit removal policy; absence of `resultExpiresAt` is not a + guarantee against such removal. +- Receipts survive payload expiry and transcript pruning for the agreed + duplicate-delivery lifetime. No receipt eviction or session-ID reuse policy + is specified here. Whole-entity TTL/deletion and receipt storage growth must + be resolved before deployment, not silently treated as transcript retention. + +Expired lookup reports completed-but-result-unavailable plus the retained +`succeeded` or `failed` outcome, with no expired payload and no reopening of +execution. This agreed contract behavior still requires aligned ADR wording +and a Python receipt/lookup update; it is not a claim about current Python +behavior or authorization to emit 2.0. + +An older receipt without an authoritative outcome must **not** be assigned +`succeeded` or `failed` from absence of an error, a pruned transcript, expiry, +or a default. Preserve its known completion/unavailability facts without +inventing an outcome or rerunning the work. The required v2 receipt `outcome` +may be populated only from authoritative evidence; absent that evidence, the +legacy receipt cannot be promoted to this v2 shape. A separately agreed legacy +lookup/migration representation must retain the distinction. This proposal +does not add a fabricated `unknown` outcome to the v2 enum. + +The proposed envelope requires `response.messages` for success and failure +(an empty list is allowed). Failure additionally requires `error.code` and +`error.message`; success forbids `error`. Error details and response metadata +are JSON only. A failure's partial messages are diagnostic output, not an +instruction to replay a failed turn. Whether failures should instead use an +error-only union, and how cancellation is represented, remain review questions. + +`response.value` is an optional, named caller-visible JSON result, independent +of messages and text. An absent field means no structured value; explicit +`null`, `false`, `0`, `""`, `[]`, and `{}` are present values and must survive +round-tripping without truthiness-based omission. No separate presence flag +is needed. Consumers must not infer `value` from text, coerce its type, or +serialize arbitrary model/runtime objects. Unknown nested properties remain +part of the value. The same JSON preservation rule applies if a failed response +carries a diagnostic value; its outcome remains failed. + +### Optional runtime extension/profile + +Stable provider/configuration identity is distinct from effective per-run +history ownership. The shared contract **does not require session-fixed +effective ownership** and must not prohibit Python's supported per-run +transitions. C# may separately enforce a fixed-owner policy/profile. + +`historyBinding` now denotes an optional, separately versioned runtime +extension/profile rather than a standardized shared binding object. The existing +spelling is retained to avoid moving stored data; there is no new shared +`providerBinding` or `configurationBinding` definition. Compatible writers must +preserve the original JSON value and all nested fields, even when they do not +understand or use that profile. The shared schema intentionally does not validate +its internal shape, version, owner kinds, or provider keys. + +A runtime that relies on the profile for restoration must validate the profile +identity, supported version, required fields, and applicable policy before use, +using trusted configuration rather than dynamically activating types from JSON. +Unsupported or malformed profiles must fail that runtime's restoration path; +they must not be silently ignored when the runtime depends on them. Other +consumers preserve the data without treating shared-schema validation as profile +approval. Even null or a malformed profile can be preserved as opaque JSON; +that does not make it usable by a relying runtime. + +The `version`, `ownerKind`, and non-secret logical `providerKey` in existing +synthetic fixtures illustrate one runtime profile, not a shared mandatory shape. +Profile-specific fixed ownership remains runtime policy. Absence implies neither +a default owner nor permission to infer one from opaque session state. No profile +is an authorization grant. A shared descriptor can be proposed later when there +is a concrete common consumer; no effective-owner transition protocol is imposed. + +## Existing state, extension data, and trust boundaries + +`session` is opaque JSON continuation/provider state. Preserve it, but do not +infer an owner or instantiate a runtime type from its contents. The optional +terminal `continuationToken` is base64 bytes; cross-language encoding does not +prove cross-provider resumability. Its byte contract needs joint agreement. + +`expirationTimeUtc` documents the existing whole-entity idle TTL field; absent +or null means no stored deadline. Deleting the entity also deletes receipts: +this is distinct from `resultExpiresAt` and ends any in-entity deduplication +evidence. An external tombstone, bounded request lifetime, or session-generation +policy must address late duplicates before such deletion is compatible with 2.0. + +`ingestedPositions` is a legacy highest-seen position by producer, **not proof +of a contiguous delivered prefix**, an exact receipt set, or terminal completion. +Migration must preserve its historical meaning and cannot infer that gaps were +delivered. After delivering `[1, 3]`, selecting `[2, 4]` must deliver both while +remembering that `3` was delivered. A scalar `3` alone cannot establish that. +Exact gap-preserving workflow receipt sets or ranges need separately versioned +bookkeeping with explicit producer/delivery identity, preservation, and migration +semantics, independent of transcript retention. This proposal neither invents +that wire format nor backfills receipts from the scalar. + +`truncation` records evicted message count and first/last eviction +instants (last must be no earlier than first). It is diagnostic evidence, not +model context. Optional `messageId` is message identity, not request identity. + +### Lossless messages and JSON content + +For schema 2.0 only, message roles include `developer`. Function-call `arguments` accepts the +original object or string; preserve the entire string, including whitespace +and incomplete/non-JSON text, without parsing it into an object. URI content +requires a URI but not a media type; absence stays absent rather than being +filled with guessed metadata. Versioned message/content definitions keep these +expansions out of historical 1.x validation, including when the same message is +placed in a request, response, error response, or compaction transcript entry. +The terminal-response message path also uses the v2 definitions. + +For supported content not represented by a typed definition, a producer may +use the explicit `{"$type":"unknown","content":...}` wrapper with the complete +original JSON value, including its metadata. An opaque payload with its own +`type` or `$type` remains data and must not select or activate runtime types. +Only a safe, explicit JSON representation qualifies; no arbitrary object +reflection, `repr` fallback, or executable serialization is implied. If a +supported value cannot be preserved safely, report the incompatibility rather +than silently dropping it or claiming a lossless terminal result was persisted. +The v2 lossless producer-mapping requirement does not widen the old explicit +`unknown.content` JSON shape: arbitrary JSON inside that wrapper was already +valid historically and remains valid. Historical consumers are not newly +required to understand v2 producer mappings. + +Lossless here means JSON value preservation, including array order, exact +string contents, numeric fidelity, and absent versus null fields. It does not +require byte-identical JSON formatting or object-property order. Wrapping is a +producer mapping for supported unmodeled content, not permission for a reader +to hide malformed known wire fields or accept unknown wire discriminators. + +Unknown properties are allowed and must round-trip **at their original object +locations**, independently of explicit `extensionData` objects, including +nested content and mailbox fields. Known fields must still satisfy their +declared types; do not hide malformed values in extension data. This preservation +rule is a requirement on future compatible readers/writers, not a description +of every current serializer. Metadata cannot override known envelope fields. + +Unknown properties are not unknown discriminators: 2.0 accepts only transcript +`$type` values `request`, `response`, `errorResponse`, and `compaction`. +Compaction has no `correlationId`. Content `$type` values are `data`, `error`, +`functionCall`, `functionResult`, `hostedFile`, `hostedVectorStore`, `usage`, +`text`, `reasoning`, `uri`, and the explicit `unknown` wrapper. Unsupported +versions, outcomes, availability values, roles, +or discriminators must be rejected for processing, not silently converted to +success or to an empty unknown-content wrapper. Opaque `unknown.content` itself +can be any JSON value, including null; `$runtimeType` inside it is just data. +Runtime-profile discriminators and versions are different: validate them only +when relying on that profile, and otherwise preserve them without interpretation. + +Persisted JSON is untrusted data. Never dynamically load types, follow URIs, +execute tool calls, or log opaque session/error/token contents merely by reading +state. Normal host authorization, redaction, and total storage/depth limits are +still required. Identifier limits count Unicode code points, not UTF-16 units. +Identifiers are nonblank and exclude C0/C1 controls; metadata is not executable. +Usage metadata retains arbitrary JSON even if a runtime cannot represent it as +numeric counts. Integer ranges, timestamp precision, and provider-specific +continuation formats require cross-language agreement; validation alone does +not ensure lossless projection. No provider or retention policy is implemented. + +## Rollout, migration, and maintainer questions + +Before any runtime emits 2.0, agree on and enforce a deployment floor covering +state readers, duplicate lookups, pollers, writers, rollback writers, hosting +consumers, and tools such as the scheduler dashboard. Participants that may +process 2.0 must implement its behavior; others must reject it before processing +or mutation and be isolated from 2.0 routing. Merely preserving fields is not +enough. Rollback to a transcript-only writer must be prevented once 2.0 exists. + +Do not migrate by changing only `schemaVersion` or by adding empty receipt maps +to previously used 1.x state. Pruned transcript cannot prove prior completion +or reconstruct immutable responses. Migration needs authoritative completion +evidence or an explicitly isolated new session generation with agreed duplicate +handling. This PR contains neither a migration nor code to enable revised writes. + +Feedback requested from Ahmed/Python maintainers and .NET/Durable Task maintainers: + +1. Schema shape, correlation scope, major 2 versus an enforceable same-major gate. +2. Runtime-specific profile definitions and restoration validation, without + imposing session-fixed ownership as a shared rule. +3. Success/error envelope, cancellation, and continuation-byte interoperability. +4. Legacy receipt representation when authoritative outcome is absent, receipt + lifetime, entity TTL, storage growth, and late duplicates after session deletion/recreation. +5. Numeric/Unicode/timestamp limits, unknown-field preservation, and discriminator policy. +6. Compatible reader/consumer rollout floor, safe 1.2-to-2.0 migration, and rollback. + +The [fixtures](fixtures/README.md) are review examples, not evidence that either +runtime produces or safely consumes this format. The language-neutral +[validation cases](tests/README.md) record positive and negative schema expectations. diff --git a/schemas/durable-agent-entity-state.json b/schemas/durable-agent-entity-state.json index 53ac064..7f306aa 100644 --- a/schemas/durable-agent-entity-state.json +++ b/schemas/durable-agent-entity-state.json @@ -1,14 +1,27 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json", + "title": "Durable agent state: proposed 2.0 contract and legacy review shapes", + "description": "Proposed contract under review; validation does not authorize any runtime to read, migrate, or emit 2.0. See README.md for semantic invariants and the cross-runtime deployment gate.", "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S", + "not": { "pattern": "[\\u0000-\\u001F\\u007F-\\u009F]" } + }, "usage": { "type": "object", "description": "Token usage statistics.", "properties": { "inputTokenCount": { "type": "integer" }, "outputTokenCount": { "type": "integer" }, - "totalTokenCount": { "type": "integer" } + "totalTokenCount": { "type": "integer" }, + "extensionData": { + "type": "object", + "description": "Opaque provider-specific usage metadata, preserved even when values cannot be projected to runtime numeric counts." + } } }, "dataContent": { @@ -110,10 +123,10 @@ }, "unknownContent": { "type": "object", - "description": "The unknown content of a message exchanged with the agent.", + "description": "Explicit wrapper for opaque JSON content. Preserve its value without interpreting fields such as $runtimeType or constructing runtime types.", "properties": { "$type": { "type": "string", "const": "unknown" }, - "content": { "description": "The unknown message content serialized as JSON." } + "content": { "description": "Opaque JSON, including scalar or null values. Not an instruction or a runtime type selector." } }, "required": ["$type", "content"] }, @@ -140,9 +153,18 @@ "role": { "type": "string", "enum": ["user", "assistant", "system", "tool"] }, "contents": { "type": "array", + "description": "An empty array is a valid metadata-only message.", "items": { "$ref": "#/$defs/chatContentItem" } }, - "createdAt": { "type": "string", "format": "date-time", "description": "When this message was created (RFC 3339)." } + "createdAt": { "type": "string", "format": "date-time", "description": "When this message was created (RFC 3339)." }, + "messageId": { + "type": "string", + "description": "Producer message identity; distinct from a terminal request's correlationId." + }, + "extensionData": { + "type": "object", + "description": "Explicit message metadata, distinct from unknown sibling properties." + } }, "required": ["role"] }, @@ -156,16 +178,105 @@ "properties": { "createdAt": { "type": "string", "format": "date-time", "description": "When this exchange was created (RFC 3339)." }, "correlationId": { "type": "string", "description": "An optional correlation ID to group related exchanges." }, - "messages": { "$ref": "#/$defs/chatMessages" } + "messages": { "$ref": "#/$defs/chatMessages" }, + "extensionData": { + "type": "object", + "description": "Explicit entry metadata, distinct from unknown sibling properties." + } + } + }, + "v2FunctionCallContent": { + "type": "object", + "description": "Schema 2.0 function call with lossless original argument form.", + "properties": { + "$type": { "type": "string", "const": "functionCall" }, + "callId": { "type": "string", "description": "The identifier of the function being called." }, + "name": { "type": "string", "description": "The name of the function being called." }, + "arguments": { + "type": ["object", "string"], + "description": "Original function arguments. Preserve string form verbatim, including incomplete or non-JSON text; do not parse, normalize, or replace it with an object." + } + }, + "required": ["$type", "callId", "name"] + }, + "v2UriContent": { + "type": "object", + "description": "Schema 2.0 URI content; absent media type remains absent.", + "properties": { + "$type": { "type": "string", "const": "uri" }, + "uri": { "type": "string", "description": "The URI." }, + "mediaType": { "type": "string", "description": "The media type of the URI, if supplied." } + }, + "required": ["$type", "uri"] + }, + "v2UnknownContent": { + "$ref": "#/$defs/unknownContent", + "description": "Schema 2.0 lossless producer mapping for supported unmodeled JSON content. Preserve the complete original JSON value and metadata without interpreting fields such as $runtimeType or constructing runtime types. Not a fallback for malformed known wire fields. The existing opaque wrapper's accepted JSON shapes are unchanged." + }, + "v2ChatContentItem": { + "oneOf": [ + { "$ref": "#/$defs/dataContent" }, + { "$ref": "#/$defs/errorContent" }, + { "$ref": "#/$defs/v2FunctionCallContent" }, + { "$ref": "#/$defs/functionResultContent" }, + { "$ref": "#/$defs/hostedFileContent" }, + { "$ref": "#/$defs/hostedVectorStoreContent" }, + { "$ref": "#/$defs/usageContent" }, + { "$ref": "#/$defs/textContent" }, + { "$ref": "#/$defs/textReasoningContent" }, + { "$ref": "#/$defs/v2UriContent" }, + { "$ref": "#/$defs/v2UnknownContent" } + ] + }, + "v2ChatMessage": { + "type": "object", + "description": "Schema 2.0 chat message. Historical versions continue to use chatMessage.", + "properties": { + "authorName": { "type": "string", "description": "The name of the author of the message." }, + "role": { "type": "string", "enum": ["user", "assistant", "system", "developer", "tool"] }, + "contents": { + "type": "array", + "description": "An empty array is a valid metadata-only message.", + "items": { "$ref": "#/$defs/v2ChatContentItem" } + }, + "createdAt": { "type": "string", "format": "date-time", "description": "When this message was created (RFC 3339)." }, + "messageId": { + "type": "string", + "description": "Producer message identity; distinct from a terminal request's correlationId." + }, + "extensionData": { + "type": "object", + "description": "Explicit message metadata, distinct from unknown sibling properties." + } + }, + "required": ["role"] + }, + "v2ChatMessages": { + "type": "array", + "description": "Ordered list of schema 2.0 chat messages.", + "items": { "$ref": "#/$defs/v2ChatMessage" } + }, + "v2ConversationEntry": { + "type": "object", + "properties": { + "createdAt": { "type": "string", "format": "date-time", "description": "When this exchange was created (RFC 3339)." }, + "correlationId": { "type": "string", "description": "An optional correlation ID to group related exchanges." }, + "messages": { "$ref": "#/$defs/v2ChatMessages" }, + "extensionData": { + "type": "object", + "description": "Explicit entry metadata, distinct from unknown sibling properties." + } } }, "agentRequest": { "allOf": [ - { "$ref": "#/$defs/conversationEntry" } + { "$ref": "#/$defs/v2ConversationEntry" } ], "description": "The request (i.e. prompt) sent to the agent.", + "required": ["$type"], "properties": { "$type": { "type": "string", "const": "request" }, + "correlationId": { "$ref": "#/$defs/identifier" }, "orchestrationId": { "type": "string", "description": "The identifier of the orchestration that initiated this agent request (if any)." @@ -182,36 +293,245 @@ }, "agentResponse": { "allOf": [ - { "$ref": "#/$defs/conversationEntry" } + { "$ref": "#/$defs/v2ConversationEntry" } ], "description": "The response received from the agent.", + "required": ["$type"], "properties": { "$type": { "type": "string", "const": "response" }, + "correlationId": { "$ref": "#/$defs/identifier" }, "usage": { "$ref": "#/$defs/usage" } } }, + "agentErrorResponse": { + "allOf": [ + { "$ref": "#/$defs/v2ConversationEntry" } + ], + "description": "Diagnostic record of a failed turn, not model replay context or authoritative completion evidence.", + "required": ["$type"], + "properties": { + "$type": { "type": "string", "const": "errorResponse" }, + "correlationId": { "$ref": "#/$defs/identifier" }, + "usage": { "$ref": "#/$defs/usage" } + } + }, + "compaction": { + "allOf": [ + { "$ref": "#/$defs/v2ConversationEntry" } + ], + "description": "Compacted model context; it answers no request and has no correlationId.", + "required": ["$type"], + "properties": { + "$type": { "type": "string", "const": "compaction" }, + "correlationId": false + } + }, + "terminalResponse": { + "type": "object", + "description": "JSON-only terminal response projection. No raw runtime object graph is persisted.", + "properties": { + "messages": { "$ref": "#/$defs/v2ChatMessages" }, + "value": { + "type": ["null", "boolean", "number", "string", "array", "object"], + "description": "Optional caller-visible structured JSON result, independent of messages. Absence means no structured value; explicit null, false, zero, empty strings, arrays, and objects remain present values. No runtime object serialization or inferred value from message text." + }, + "usage": { "$ref": "#/$defs/usage" }, + "createdAt": { "type": "string", "format": "date-time" }, + "responseId": { "$ref": "#/$defs/identifier" }, + "agentId": { "$ref": "#/$defs/identifier" }, + "finishReason": { "$ref": "#/$defs/identifier" }, + "continuationToken": { + "type": "string", + "maxLength": 16384, + "contentEncoding": "base64", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + "not": { "pattern": "[^A-Za-z0-9+/=]" }, + "description": "Opaque base64-encoded continuation bytes. Encoding alone does not imply that another runtime or provider can resume them." + }, + "extensionData": { + "type": "object", + "description": "Explicit JSON response metadata; preserve separately from unknown sibling properties.", + "propertyNames": { "$ref": "#/$defs/identifier" } + } + }, + "required": ["messages"], + "additionalProperties": true + }, + "terminalError": { + "type": "object", + "description": "Sanitized JSON terminal failure metadata, not a serialized exception or executable type.", + "properties": { + "code": { "$ref": "#/$defs/identifier" }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 16384, + "pattern": "\\S" + }, + "details": { "description": "Optional JSON failure details; subject to privacy and storage limits." } + }, + "required": ["code", "message"], + "additionalProperties": true + }, + "terminalResult": { + "type": "object", + "description": "Immutable terminal result detached from conversationHistory. Map-key equality and receipt consistency require semantic validation.", + "properties": { + "correlationId": { "$ref": "#/$defs/identifier" }, + "outcome": { "type": "string", "enum": ["succeeded", "failed"] }, + "completedAt": { "type": "string", "format": "date-time" }, + "resultExpiresAt": { "type": "string", "format": "date-time" }, + "response": { "$ref": "#/$defs/terminalResponse" }, + "error": { "$ref": "#/$defs/terminalError" } + }, + "required": ["correlationId", "outcome", "completedAt", "response"], + "allOf": [ + { + "if": { "properties": { "outcome": { "const": "succeeded" } } }, + "then": { "not": { "required": ["error"] } } + }, + { + "if": { "properties": { "outcome": { "const": "failed" } } }, + "then": { "required": ["error"] } + } + ], + "additionalProperties": true + }, + "completionReceipt": { + "type": "object", + "description": "Completion evidence surviving result expiry. Identity, authoritative outcome, completion time, and expiry policy are immutable; availability may transition once from available to unavailable. Expired lookup retains outcome without a payload. An older receipt lacking authoritative outcome cannot be promoted to this shape by inventing succeeded or failed.", + "properties": { + "correlationId": { "$ref": "#/$defs/identifier" }, + "outcome": { "type": "string", "enum": ["succeeded", "failed"] }, + "completedAt": { "type": "string", "format": "date-time" }, + "resultState": { "type": "string", "enum": ["available", "unavailable"] }, + "resultExpiresAt": { "type": "string", "format": "date-time" }, + "resultUnavailableAt": { "type": "string", "format": "date-time" } + }, + "required": ["correlationId", "outcome", "completedAt", "resultState"], + "allOf": [ + { + "if": { "properties": { "resultState": { "const": "available" } } }, + "then": { "not": { "required": ["resultUnavailableAt"] } } + }, + { + "if": { "properties": { "resultState": { "const": "unavailable" } } }, + "then": { "required": ["resultUnavailableAt"] } + } + ], + "additionalProperties": true + }, + "historyBinding": { + "description": "Optional separately versioned runtime extension/profile, not a shared binding object or effective-owner policy. The existing field name is retained for preservation. Compatible writers preserve the original JSON value; only a runtime relying on it validates its recognized profile and version before use. Shared shape validation does not imply the value is a usable profile." + }, "data": { "type": "object", "description": "The durable agent's state data.", "properties": { "conversationHistory": { "type": "array", - "description": "Ordered list of conversation entries.", - "items": { "$ref": "#/$defs/conversationEntry" } + "description": "Ordered, evictable transcript. The root schemaVersion selects historical conversationEntry items or the known schema 2.0 entry definitions." + }, + "terminalResults": { + "type": "object", + "description": "Immutable results keyed by exact, case-sensitive request correlationId within this entity/session generation.", + "propertyNames": { "$ref": "#/$defs/identifier" }, + "additionalProperties": { "$ref": "#/$defs/terminalResult" } + }, + "completionReceipts": { + "type": "object", + "description": "Terminal evidence keyed by correlationId; independent of transcript and result-payload retention.", + "propertyNames": { "$ref": "#/$defs/identifier" }, + "additionalProperties": { "$ref": "#/$defs/completionReceipt" } + }, + "historyBinding": { "$ref": "#/$defs/historyBinding" }, + "session": { + "type": "object", + "description": "Opaque JSON session continuation and provider state. Preserve without using its fields to select or construct runtime types." + }, + "expirationTimeUtc": { + "type": ["string", "null"], + "format": "date-time", + "description": "Existing whole-entity idle TTL deadline, not result expiry. Absent or null means no stored deadline. Whole-entity deletion removes receipts too; a separate deduplication-lifetime agreement is required." + }, + "ingestedPositions": { + "type": "object", + "description": "Legacy highest-seen position by producer, not proof of a contiguous delivered prefix or request completion. Preserve historical values without inferring missing deliveries during migration. Exact gap-preserving workflow receipts require separately versioned bookkeeping independent of transcript retention.", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "truncation": { + "type": "object", + "description": "Diagnostic evidence of transcript removal; not model context or terminal completion evidence.", + "properties": { + "evictedMessageCount": { "type": "integer", "minimum": 1 }, + "firstEvictedAt": { "type": "string", "format": "date-time" }, + "lastEvictedAt": { "type": "string", "format": "date-time" } + }, + "required": ["evictedMessageCount", "firstEvictedAt", "lastEvictedAt"] + }, + "extensionData": { + "type": "object", + "description": "Explicit data metadata; preserve separately from unknown sibling properties." } - } + }, + "additionalProperties": true } }, "type": "object", "properties": { "schemaVersion": { "type": "string", - "description": "Semantic version of this state schema. By convention, this should be the first property.", - "pattern": "^\\d+\\.\\d+\\.\\d+$" + "description": "Exact review snapshots, not a runtime acceptance list. 1.2.0 and 2.0.0 are proposals; future versions require an explicit compatibility decision. By convention this is the first property.", + "enum": ["1.0.0", "1.1.0", "1.2.0", "2.0.0"] }, - "data": { "$ref": "#/$defs/data" } + "data": { "$ref": "#/$defs/data" }, + "extensionData": { + "type": "object", + "description": "Explicit root metadata; preserve separately from unknown sibling properties." + } }, - "required": ["schemaVersion", "data"] + "required": ["schemaVersion", "data"], + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { "schemaVersion": { "const": "2.0.0" } } + }, + "then": { + "properties": { + "data": { + "required": ["conversationHistory", "terminalResults", "completionReceipts"], + "properties": { + "conversationHistory": { + "items": { + "oneOf": [ + { "$ref": "#/$defs/agentRequest" }, + { "$ref": "#/$defs/agentResponse" }, + { "$ref": "#/$defs/agentErrorResponse" }, + { "$ref": "#/$defs/compaction" } + ] + } + } + } + } + } + }, + "else": { + "properties": { + "data": { + "properties": { + "conversationHistory": { + "items": { "$ref": "#/$defs/conversationEntry" } + }, + "terminalResults": false, + "completionReceipts": false, + "historyBinding": false + } + } + } + } + } + ] } diff --git a/schemas/fixtures/README.md b/schemas/fixtures/README.md new file mode 100644 index 0000000..07300c2 --- /dev/null +++ b/schemas/fixtures/README.md @@ -0,0 +1,69 @@ +# Durable agent state review fixtures + +All JSON files here are synthetic, language-neutral review data validated with +the parent [Draft 2020-12 schema](../durable-agent-entity-state.json). +They are not captured production state, generated serializer snapshots, or +evidence of Python/.NET 2.0 support. + +| Fixture | Provenance and purpose | +| --- | --- | +| `shared-durable-agent-state-1.2-python-shape.json` | Reproduced from proposal commit `247bbdd60944d5ac93e79079803aa23e992d0369`. Modeled on parallel Python-shaped 1.2 work, with synthetic future fields and content for preservation review. **Not byte-for-byte output from the current Python serializer.** | +| `shared-durable-agent-state-2.0.json` | Reproduced from the same proposal commit. Synthetic available success and expired failure receipt, detached from transcript responses; includes unknown mailbox/binding fields. | +| `shared-durable-agent-state-2.0-pruned.json` | Authored for this contract-only proposal. Empty transcript with available failure, unavailable success, opaque session data, truncation evidence, and whole-entity TTL. Not a migration output. | +| `shared-durable-agent-state-2.0-lossless.json` | Authored for review feedback. Synthetic developer-role request, verbatim string-form function arguments, URI without invented media type, complete opaque JSON content, explicit structured `false` value, and no binding. | + +The source proposal builds on `5de13e8d5dd4b4b76e7360e89ceeb3968a103781`; +its runtime DTOs, converters, tests, and test-project fixture links are +deliberately excluded. The corrected `python-shape` filename identifies +provenance, not a promise of current serializer behavior. There is no fixture +generator or test-project integration in this PR. + +The two reproduced source fixtures are unchanged as JSON values. The example +bindings are optional runtime-profile data, not a shared binding shape or proof +of session-fixed effective ownership. Compatible writers preserve them; only a +runtime relying on a profile validates its identity, version, shape, and policy. +The original legacy fixture stays valid under the unchanged historical message +definitions. The expanded lossless fixture is v2-only; its persisted bytes are +unchanged too. + +For `shared-durable-agent-state-2.0.json`, interpret the example at +`2026-09-10T05:00:05Z`: `corr-2` has an available result and `corr-expired` +proves completed failure without a payload. After `corr-2`'s expiry a compatible +poller must report completed-but-result-unavailable even before cleanup. + +For `shared-durable-agent-state-2.0-pruned.json`, interpret the example at +`2026-09-10T06:00:05Z`: `corr-failed` has an available failure payload and +`corr-pruned` proves completed success without one. Transcript removal did not +erase either completion. `expirationTimeUtc` is a separate whole-entity deadline, +not a proposed resolution of tombstone lifetime after entity deletion. + +The lossless fixture intentionally contains incomplete function-argument text. +The exact string must survive; parsing it, completing the JSON, or replacing it +with an object would lose information. The URI has no `mediaType` to infer. The +opaque content's nested `$type` and `$runtimeType` are inert JSON, including all +metadata, not type-activation instructions. `response.value: false` is a present +structured result; the earlier fixtures omit `value`. Null, zero, and empty +values have separate [validation cases](../tests/README.md). + +The lossless fixture's scalar `ingestedPositions["legacy-producer"] = 3` records +only highest-seen position. It does not say whether `2` was delivered. None of +these fixtures defines or infers a gap-preserving workflow receipt set. + +The expired receipts retain `outcome`; lookup reports that retained outcome with +completed-but-result-unavailable, without restoring a payload or reopening +execution. This contract still requires the corresponding ADR/Python updates. +No fixture claims that a legacy receipt without authoritative outcome can be +converted by inventing success or failure. Such a receipt cannot satisfy the +v2 required-outcome shape without authoritative evidence. + +For an identity absent from the receipt maps, the examples show only the +absence of recorded completion: a separately accepted request may be pending, +whereas an unrecognized identity remains unknown. No fixture invents an +admission registry, claims exactly-once external side effects, or authorizes +schema-version-only migration. The 1.2 and 2.0 examples are independent +snapshots, not a before/after pair with inferred completion evidence. + +Validation must enable Draft 2020-12 and date-time format checking, then check +the cross-map/time invariants in [the proposal](../README.md). JSON Schema alone +cannot enforce key equality, atomicity, historical immutability, expiry relative +to a clock, or compatibility of a deployed reader. diff --git a/schemas/fixtures/shared-durable-agent-state-1.2-python-shape.json b/schemas/fixtures/shared-durable-agent-state-1.2-python-shape.json new file mode 100644 index 0000000..ada0f64 --- /dev/null +++ b/schemas/fixtures/shared-durable-agent-state-1.2-python-shape.json @@ -0,0 +1,168 @@ +{ + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "correlationId": "corr-python", + "createdAt": "2026-07-27T12:34:50+00:00", + "messages": [ + { + "role": "user", + "messageId": "producer-request-id", + "extensionData": { + "origin": "python" + }, + "contents": [ + { + "$type": "text", + "text": "hello" + } + ] + } + ], + "extensionData": { + "requestFuture": true + } + }, + { + "$type": "response", + "correlationId": "corr-python", + "createdAt": "2026-07-27T12:34:51+00:00", + "messages": [ + { + "role": "assistant", + "authorName": "python-agent", + "createdAt": "2026-07-27T12:34:51+00:00", + "messageId": "python-metadata-only", + "extensionData": { + "metadataOrigin": "python" + }, + "contents": [] + }, + { + "role": "assistant", + "contents": [ + { + "$type": "text", + "text": "world" + } + ] + }, + { + "role": "assistant", + "contents": [ + { + "$type": "reasoning", + "text": "private reasoning" + } + ] + }, + { + "role": "assistant", + "messageId": "python-unknown-content", + "contents": [ + { + "$type": "unknown", + "content": { + "$runtimeType": "python-owned-user-field", + "type": "future_python_content", + "payload": "python-value", + "annotations": [ + { + "kind": "citation", + "value": "python-ref" + } + ], + "additional_properties": { + "producer": "python" + }, + "future_payload": { + "nested": [ + 1, + 2, + 3 + ] + } + } + } + ] + } + ], + "usage": { + "inputTokenCount": 4, + "outputTokenCount": 2, + "totalTokenCount": 6, + "extensionData": { + "providerCount": 7, + "futureNumeric": 11, + "futureString": "seven", + "futureObject": { + "count": 8 + }, + "futureArray": [ + 9 + ] + } + } + }, + { + "$type": "errorResponse", + "correlationId": "corr-error", + "createdAt": "2026-07-27T12:34:52+00:00", + "messages": [ + { + "role": "assistant", + "contents": [ + { + "$type": "error", + "message": "failed", + "errorCode": "Example" + } + ] + } + ] + }, + { + "$type": "compaction", + "createdAt": "2026-07-27T12:34:56.123456+00:00", + "messages": [ + { + "role": "assistant", + "contents": [ + { + "$type": "text", + "text": "summary" + } + ] + } + ] + } + ], + "session": { + "type": "session", + "session_id": "@dafx-agent@session", + "state": { + "custom": { + "value": 1 + } + } + }, + "ingestedPositions": { + "input": 0, + "writer": 3 + }, + "extensionData": { + "dataProducer": "python" + }, + "futureDataProperty": { + "preserve": true + } + }, + "extensionData": { + "rootProducer": "interop-fixture" + }, + "futureRootProperty": { + "preserve": true + } +} diff --git a/schemas/fixtures/shared-durable-agent-state-2.0-lossless.json b/schemas/fixtures/shared-durable-agent-state-2.0-lossless.json new file mode 100644 index 0000000..a0b79df --- /dev/null +++ b/schemas/fixtures/shared-durable-agent-state-2.0-lossless.json @@ -0,0 +1,77 @@ +{ + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "correlationId": "corr-lossless", + "messages": [ + { + "role": "developer", + "contents": [ + { + "$type": "text", + "text": "Preserve the original result." + } + ] + } + ] + } + ], + "terminalResults": { + "corr-lossless": { + "correlationId": "corr-lossless", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "response": { + "messages": [ + { + "role": "assistant", + "contents": [ + { + "$type": "functionCall", + "callId": "tool-example", + "name": "example_tool", + "arguments": " { \"partial\": " + }, + { + "$type": "uri", + "uri": "https://example.test/media/1" + }, + { + "$type": "unknown", + "content": { + "$type": "example_unmodeled_media", + "$runtimeType": "opaque-data-only", + "bytes": "AQID", + "metadata": { + "presentNull": null, + "empty": [], + "annotations": [ + { + "kind": "synthetic" + } + ] + } + } + } + ] + } + ], + "value": false + } + } + }, + "completionReceipts": { + "corr-lossless": { + "correlationId": "corr-lossless", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "resultState": "available" + } + }, + "ingestedPositions": { + "legacy-producer": 3 + } + } +} diff --git a/schemas/fixtures/shared-durable-agent-state-2.0-pruned.json b/schemas/fixtures/shared-durable-agent-state-2.0-pruned.json new file mode 100644 index 0000000..6ac7777 --- /dev/null +++ b/schemas/fixtures/shared-durable-agent-state-2.0-pruned.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "corr-failed": { + "correlationId": "corr-failed", + "outcome": "failed", + "completedAt": "2026-09-10T06:00:00Z", + "response": { + "messages": [] + }, + "error": { + "code": "example_dependency_unavailable", + "message": "The example dependency could not complete the request.", + "details": { + "synthetic": true + } + } + } + }, + "completionReceipts": { + "corr-failed": { + "correlationId": "corr-failed", + "outcome": "failed", + "completedAt": "2026-09-10T06:00:00Z", + "resultState": "available" + }, + "corr-pruned": { + "correlationId": "corr-pruned", + "outcome": "succeeded", + "completedAt": "2026-09-09T06:00:00Z", + "resultState": "unavailable", + "resultExpiresAt": "2026-09-10T06:00:00Z", + "resultUnavailableAt": "2026-09-10T06:00:01Z" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "example.local-history.v1" + }, + "session": { + "exampleContinuation": { + "cursor": "synthetic-cursor", + "$runtimeType": "opaque-user-data" + } + }, + "expirationTimeUtc": "2026-10-10T06:00:00Z", + "ingestedPositions": { + "example-producer": 3 + }, + "truncation": { + "evictedMessageCount": 4, + "firstEvictedAt": "2026-09-10T06:00:02Z", + "lastEvictedAt": "2026-09-10T06:00:03Z" + } + } +} diff --git a/schemas/fixtures/shared-durable-agent-state-2.0.json b/schemas/fixtures/shared-durable-agent-state-2.0.json new file mode 100644 index 0000000..3a2702d --- /dev/null +++ b/schemas/fixtures/shared-durable-agent-state-2.0.json @@ -0,0 +1,100 @@ +{ + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "correlationId": "corr-2", + "createdAt": "2026-09-10T05:00:00+00:00", + "messages": [ + { + "role": "user", + "messageId": "request-2", + "contents": [ + { + "$type": "text", + "text": "Generate the artifact." + } + ] + } + ] + } + ], + "terminalResults": { + "corr-2": { + "correlationId": "corr-2", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:03+00:00", + "resultExpiresAt": "2026-09-11T05:00:03+00:00", + "response": { + "messages": [ + { + "role": "assistant", + "authorName": "fixture-agent", + "messageId": "response-2", + "contents": [ + { + "$type": "text", + "text": "Artifact generated." + }, + { + "$type": "uri", + "uri": "https://example.test/artifacts/2", + "mediaType": "application/json" + } + ] + } + ], + "usage": { + "inputTokenCount": 5, + "outputTokenCount": 3, + "totalTokenCount": 8 + }, + "createdAt": "2026-09-10T05:00:03+00:00", + "responseId": "response-id-2", + "agentId": "agent-id-2", + "finishReason": "stop", + "continuationToken": "AQID", + "extensionData": { + "region": "test", + "attempt": 1 + }, + "futureResponseField": { + "preserve": true + } + }, + "futureResultField": "preserve" + } + }, + "completionReceipts": { + "corr-2": { + "correlationId": "corr-2", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:03+00:00", + "resultState": "available", + "resultExpiresAt": "2026-09-11T05:00:03+00:00", + "futureReceiptField": 7 + }, + "corr-expired": { + "correlationId": "corr-expired", + "outcome": "failed", + "completedAt": "2026-09-09T05:00:03+00:00", + "resultState": "unavailable", + "resultExpiresAt": "2026-09-10T05:00:03+00:00", + "resultUnavailableAt": "2026-09-10T05:00:04+00:00" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "historyProvider", + "providerKey": "contoso.support-history.v1", + "futureBindingField": "preserve" + }, + "futureDataField": { + "preserve": true + } + }, + "futureRootField": { + "preserve": true + } +} diff --git a/schemas/tests/README.md b/schemas/tests/README.md new file mode 100644 index 0000000..70a102a --- /dev/null +++ b/schemas/tests/README.md @@ -0,0 +1,58 @@ +# Language-neutral schema validation cases + +`validation-cases.json` and `versioned-envelope-cases.json` record positive and negative review expectations using +the JSON Schema Test Suite's group shape: `description`, `schema`, and `tests`; +each test has `description`, `data`, and `valid`. These are test data, not durable +state fixtures, product implementation, or runtime test-project integration. + +Resolve the canonical schema ID locally to `../durable-agent-entity-state.json`; +do not fetch it from GitHub, which might contain a different revision. Use a +Draft 2020-12 validator. The cases cover v2-only correlation constraints, +unchanged legacy/compaction handling, opaque runtime profiles, v2 lossless +message shapes, structured-value presence, and historical ingestion scalars. + +The versioned cases use complete root envelopes for every version rather than +just testing `$defs` fragments. Historical `1.0.0`, `1.1.0`, and `1.2.0` reject +the newly widened developer role, string-form function arguments, and URI content +without media type; v2 accepts them in its transcript and terminal payload paths. +Historical explicit `unknown` JSON was already valid and stays valid in all +versions. The unchanged legacy fixture remains additional compatibility evidence. +Profile cases distinguish opaque shared preservation from validation by a relying +runtime. Outcome cases reject attempts to promote receipts lacking authoritative +outcome into the required-outcome v2 shape; they do not implement migration. + +For example, from the repository root with an existing Python `jsonschema` +installation, this PowerShell command runs the structural cases without any +network access or dependency installation: + +```powershell +@' +import json +from pathlib import Path +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + +schema = json.loads(Path(r"schemas\durable-agent-entity-state.json").read_text(encoding="utf-8")) +Draft202012Validator.check_schema(schema) +registry = Registry().with_resource(schema["$id"], Resource.from_contents(schema)) +count = 0 +for path in sorted(Path(r"schemas\tests").glob("*-cases.json")): + groups = json.loads(path.read_text(encoding="utf-8")) + for group in groups: + validator = Draft202012Validator(group["schema"], registry=registry) + for test in group["tests"]: + actual = validator.is_valid(test["data"]) + if actual != test["valid"]: + raise AssertionError(f'{path.name}: {group["description"]}: {test["description"]}') + count += 1 +print(f"Passed {count} structural validation cases") +'@ | python - +``` + +These cases have no timestamp-format assertions. Validate the separate state +fixtures with date-time format checking as well as the cross-map/time invariants +in the shared proposal. Test data alone cannot prove serializer round-tripping, +atomic entity commits, per-run ownership transitions, or runtime lookup behavior. +Future runtime implementations must additionally verify that original string +arguments, absent media types, opaque JSON metadata, and all present `value` +forms survive persistence without conversion, omission, or invented data. diff --git a/schemas/tests/validation-cases.json b/schemas/tests/validation-cases.json new file mode 100644 index 0000000..5eca503 --- /dev/null +++ b/schemas/tests/validation-cases.json @@ -0,0 +1,226 @@ +[ + { + "description": "Version-scoped correlation IDs and optional binding", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json" }, + "tests": [ + { + "description": "V2 with empty transcript and no binding", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 request without a correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "request" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 response without a correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 error response without a correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "errorResponse" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 request with a valid correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "request", "correlationId": "Case-sensitive-id" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 response with a valid correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "correlationId": "Case-sensitive-id" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 error response with a valid correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "errorResponse", "correlationId": "Case-sensitive-id" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 blank request correlation is rejected", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "request", "correlationId": "" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "V2 whitespace response correlation is rejected", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "correlationId": " " }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "V2 control-character error correlation is rejected", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "errorResponse", "correlationId": "id\u0000" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "V2 trailing newline correlation is rejected", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "request", "correlationId": "id\n" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "V2 null correlation is not omission", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "correlationId": null }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "Legacy blank correlation remains unchanged", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "$type": "request", "correlationId": "" }] } }, + "valid": true + }, + { + "description": "Legacy whitespace correlation remains unchanged", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "$type": "response", "correlationId": " " }] } }, + "valid": true + }, + { + "description": "Proposed 1.2 control-character correlation remains unchanged", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "$type": "errorResponse", "correlationId": "id\u0000" }] } }, + "valid": true + }, + { + "description": "V2 compaction still has no correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "compaction" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 compaction rejects even a valid correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "compaction", "correlationId": "valid-id" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "Unknown V2 entry discriminator is rejected", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "future" }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "Shared validation preserves opaque profile data without approving it for restoration", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": {}, "historyBinding": {} } }, + "valid": true + }, + { + "description": "Opaque null profile is preserved, not interpreted as a usable profile", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": {}, "historyBinding": null } }, + "valid": true + }, + { + "description": "Binding remains forbidden in legacy state", + "data": { "schemaVersion": "1.2.0", "data": { "historyBinding": { "version": 1, "ownerKind": "historyProvider", "providerKey": "example.config" } } }, + "valid": false + } + ] + }, + { + "description": "Schema 2.0 lossless message content", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json#/$defs/v2ChatMessage" }, + "tests": [ + { "description": "Developer role", "data": { "role": "developer", "contents": [] }, "valid": true }, + { "description": "Unknown role remains invalid", "data": { "role": "future" }, "valid": false }, + { + "description": "Verbatim non-JSON function arguments", + "data": { "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": " { \"partial\": " }] }, + "valid": true + }, + { + "description": "Original empty string arguments", + "data": { "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": "" }] }, + "valid": true + }, + { + "description": "Object arguments remain valid", + "data": { "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": { "presentNull": null } }] }, + "valid": true + }, + { + "description": "Missing arguments remain absent", + "data": { "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f" }] }, + "valid": true + }, + { + "description": "Malformed known argument type remains invalid", + "data": { "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": false }] }, + "valid": false + }, + { + "description": "URI media type may be absent", + "data": { "role": "assistant", "contents": [{ "$type": "uri", "uri": "https://example.test/media" }] }, + "valid": true + }, + { + "description": "URI media type remains valid when supplied", + "data": { "role": "assistant", "contents": [{ "$type": "uri", "uri": "https://example.test/media", "mediaType": "image/png" }] }, + "valid": true + }, + { + "description": "URI media type must not be null", + "data": { "role": "assistant", "contents": [{ "$type": "uri", "uri": "https://example.test/media", "mediaType": null }] }, + "valid": false + }, + { + "description": "URI itself remains required", + "data": { "role": "assistant", "contents": [{ "$type": "uri" }] }, + "valid": false + }, + { + "description": "Complete unmodeled JSON including inert type names", + "data": { "role": "assistant", "contents": [{ "$type": "unknown", "content": { "$type": "future", "$runtimeType": "data-only", "metadata": { "null": null, "false": false, "empty": [] } } }] }, + "valid": true + }, + { + "description": "Explicit opaque null", + "data": { "role": "assistant", "contents": [{ "$type": "unknown", "content": null }] }, + "valid": true + }, + { + "description": "Explicit opaque scalar", + "data": { "role": "assistant", "contents": [{ "$type": "unknown", "content": "verbatim" }] }, + "valid": true + }, + { + "description": "Explicit opaque array", + "data": { "role": "assistant", "contents": [{ "$type": "unknown", "content": [0, false, null] }] }, + "valid": true + }, + { + "description": "Opaque wrapper must include original content", + "data": { "role": "assistant", "contents": [{ "$type": "unknown" }] }, + "valid": false + }, + { + "description": "Unwrapped unknown wire discriminator remains invalid", + "data": { "role": "assistant", "contents": [{ "$type": "future", "content": { "preserve": true } }] }, + "valid": false + }, + { + "description": "Known malformed text is not an opaque fallback", + "data": { "role": "assistant", "contents": [{ "$type": "text", "text": 0, "content": { "preserve": true } }] }, + "valid": false + } + ] + }, + { + "description": "Named terminal structured value", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json#/$defs/terminalResponse" }, + "tests": [ + { "description": "Absent value", "data": { "messages": [] }, "valid": true }, + { "description": "Present null", "data": { "messages": [], "value": null }, "valid": true }, + { "description": "Present false", "data": { "messages": [], "value": false }, "valid": true }, + { "description": "Present zero", "data": { "messages": [], "value": 0 }, "valid": true }, + { "description": "Present empty string", "data": { "messages": [], "value": "" }, "valid": true }, + { "description": "Present empty array", "data": { "messages": [], "value": [] }, "valid": true }, + { "description": "Present empty object", "data": { "messages": [], "value": {} }, "valid": true }, + { "description": "Nested JSON remains intact", "data": { "messages": [], "value": { "items": [null, false, 0, [], {}], "$runtimeType": "inert" } }, "valid": true }, + { "description": "Value does not replace required messages", "data": { "value": false }, "valid": false } + ] + }, + { + "description": "Legacy highest-seen ingestion scalar", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json#/$defs/data/properties/ingestedPositions" }, + "tests": [ + { "description": "Highest seen three does not encode gaps", "data": { "producer": 3 }, "valid": true }, + { "description": "Zero remains valid", "data": { "producer": 0 }, "valid": true }, + { "description": "Negative position remains invalid", "data": { "producer": -1 }, "valid": false }, + { "description": "Receipt sets cannot masquerade as the legacy scalar", "data": { "producer": [1, 3] }, "valid": false } + ] + } +] diff --git a/schemas/tests/versioned-envelope-cases.json b/schemas/tests/versioned-envelope-cases.json new file mode 100644 index 0000000..052d31a --- /dev/null +++ b/schemas/tests/versioned-envelope-cases.json @@ -0,0 +1,252 @@ +[ + { + "description": "Historical 1.0.0 full-envelope message boundary", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json" }, + "tests": [ + { + "description": "Original roles, object arguments and URI with media type remain valid", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": {} }, { "$type": "uri", "uri": "https://example.test/media", "mediaType": "image/png" }] }] }] } }, + "valid": true + }, + { + "description": "Developer role is not a historical correction", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "$type": "request", "messages": [{ "role": "developer" }] }] } }, + "valid": false + }, + { + "description": "String arguments are v2-only", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": " { \"partial\": " }] }] }] } }, + "valid": false + }, + { + "description": "Empty string arguments are also v2-only", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": "" }] }] }] } }, + "valid": false + }, + { + "description": "Omitted URI media type is v2-only even on a legacy error entry", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "$type": "errorResponse", "messages": [{ "role": "assistant", "contents": [{ "$type": "uri", "uri": "https://example.test/media" }] }] }] } }, + "valid": false + }, + { + "description": "Historical explicit opaque object, scalar and null remain valid", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "unknown", "content": { "$type": "future", "$runtimeType": "inert", "metadata": [null, false, 0] } }, { "$type": "unknown", "content": "original" }, { "$type": "unknown", "content": null }] }] }] } }, + "valid": true + }, + { + "description": "Unwrapped unknown content discriminator remains invalid", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "future", "content": {} }] }] }] } }, + "valid": false + }, + { + "description": "Malformed known fields remain invalid", + "data": { "schemaVersion": "1.0.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "text", "text": false }] }] }] } }, + "valid": false + } + ] + }, + { + "description": "Historical 1.1.0 full-envelope message boundary", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json" }, + "tests": [ + { + "description": "Original roles, object arguments and URI with media type remain valid", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": {} }, { "$type": "uri", "uri": "https://example.test/media", "mediaType": "image/png" }] }] }] } }, + "valid": true + }, + { + "description": "Developer role is not a historical correction", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "$type": "request", "messages": [{ "role": "developer" }] }] } }, + "valid": false + }, + { + "description": "String arguments are v2-only", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": " { \"partial\": " }] }] }] } }, + "valid": false + }, + { + "description": "Empty string arguments are also v2-only", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": "" }] }] }] } }, + "valid": false + }, + { + "description": "Omitted URI media type is v2-only even on a legacy error entry", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "$type": "errorResponse", "messages": [{ "role": "assistant", "contents": [{ "$type": "uri", "uri": "https://example.test/media" }] }] }] } }, + "valid": false + }, + { + "description": "Historical explicit opaque object, scalar and null remain valid", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "unknown", "content": { "$type": "future", "$runtimeType": "inert", "metadata": [null, false, 0] } }, { "$type": "unknown", "content": "original" }, { "$type": "unknown", "content": null }] }] }] } }, + "valid": true + }, + { + "description": "Unwrapped unknown content discriminator remains invalid", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "future", "content": {} }] }] }] } }, + "valid": false + }, + { + "description": "Malformed known fields remain invalid", + "data": { "schemaVersion": "1.1.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "text", "text": false }] }] }] } }, + "valid": false + } + ] + }, + { + "description": "Historical 1.2.0 full-envelope message boundary", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json" }, + "tests": [ + { + "description": "Original roles, object arguments and URI with media type remain valid", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": {} }, { "$type": "uri", "uri": "https://example.test/media", "mediaType": "image/png" }] }] }] } }, + "valid": true + }, + { + "description": "Developer role is not a historical correction", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "$type": "request", "messages": [{ "role": "developer" }] }] } }, + "valid": false + }, + { + "description": "String arguments are v2-only", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": " { \"partial\": " }] }] }] } }, + "valid": false + }, + { + "description": "Empty string arguments are also v2-only", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": "" }] }] }] } }, + "valid": false + }, + { + "description": "Omitted URI media type is v2-only even on a legacy error entry", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "$type": "errorResponse", "messages": [{ "role": "assistant", "contents": [{ "$type": "uri", "uri": "https://example.test/media" }] }] }] } }, + "valid": false + }, + { + "description": "Historical explicit opaque object, scalar and null remain valid", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "unknown", "content": { "$type": "future", "$runtimeType": "inert", "metadata": [null, false, 0] } }, { "$type": "unknown", "content": "original" }, { "$type": "unknown", "content": null }] }] }] } }, + "valid": true + }, + { + "description": "Unwrapped unknown content discriminator remains invalid", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "future", "content": {} }] }] }] } }, + "valid": false + }, + { + "description": "Malformed known fields remain invalid", + "data": { "schemaVersion": "1.2.0", "data": { "conversationHistory": [{ "messages": [{ "role": "assistant", "contents": [{ "$type": "text", "text": false }] }] }] } }, + "valid": false + } + ] + }, + { + "description": "Schema 2.0 full-envelope lossless message boundary", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json" }, + "tests": [ + { + "description": "Original object arguments and URI media type remain valid", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": {} }, { "$type": "uri", "uri": "https://example.test/media", "mediaType": "image/png" }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "Developer role in a v2 request", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "request", "messages": [{ "role": "developer" }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "String arguments in a v2 response", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": " { \"partial\": " }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "Empty string arguments in a v2 response", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "c", "name": "f", "arguments": "" }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "Omitted URI media type in a v2 error entry", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "errorResponse", "messages": [{ "role": "assistant", "contents": [{ "$type": "uri", "uri": "https://example.test/media" }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 compaction uses v2 messages without a correlation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "compaction", "messages": [{ "role": "developer", "contents": [{ "$type": "uri", "uri": "https://example.test/media" }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "V2 opaque mapping preserves the original JSON", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "unknown", "content": { "$type": "future", "$runtimeType": "inert", "metadata": [null, false, 0] } }, { "$type": "unknown", "content": "original" }, { "$type": "unknown", "content": null }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": true + }, + { + "description": "Unwrapped unknown content discriminator remains invalid in v2", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "future", "content": {} }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "Malformed known fields remain invalid in v2", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "response", "messages": [{ "role": "assistant", "contents": [{ "$type": "text", "text": false }] }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "Unknown role remains invalid in v2", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [{ "$type": "request", "messages": [{ "role": "future" }] }], "terminalResults": {}, "completionReceipts": {} } }, + "valid": false + }, + { + "description": "V2 success payload independently uses expanded messages and structured value", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": { "c": { "correlationId": "c", "outcome": "succeeded", "completedAt": "2026-09-11T00:00:00Z", "response": { "messages": [{ "role": "developer", "contents": [{ "$type": "functionCall", "callId": "t", "name": "f", "arguments": " { \"partial\": " }, { "$type": "uri", "uri": "https://example.test/media" }, { "$type": "unknown", "content": { "$runtimeType": "inert", "metadata": null } }] }], "value": false } } }, "completionReceipts": { "c": { "correlationId": "c", "outcome": "succeeded", "completedAt": "2026-09-11T00:00:00Z", "resultState": "available" } } } }, + "valid": true + }, + { + "description": "V2 failure payload independently uses expanded messages", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": { "c": { "correlationId": "c", "outcome": "failed", "completedAt": "2026-09-11T00:00:00Z", "response": { "messages": [{ "role": "developer", "contents": [{ "$type": "uri", "uri": "https://example.test/media" }] }] }, "error": { "code": "example", "message": "Synthetic failure" } } }, "completionReceipts": { "c": { "correlationId": "c", "outcome": "failed", "completedAt": "2026-09-11T00:00:00Z", "resultState": "available" } } } }, + "valid": true + }, + { + "description": "V2 terminal messages still reject malformed known fields", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": { "c": { "correlationId": "c", "outcome": "succeeded", "completedAt": "2026-09-11T00:00:00Z", "response": { "messages": [{ "role": "assistant", "contents": [{ "$type": "functionCall", "callId": "t", "name": "f", "arguments": false }] }] } } }, "completionReceipts": { "c": { "correlationId": "c", "outcome": "succeeded", "completedAt": "2026-09-11T00:00:00Z", "resultState": "available" } } } }, + "valid": false + } + ] + }, + { + "description": "Opaque runtime profiles and authoritative receipt outcomes", + "schema": { "$ref": "https://github.com/microsoft/agent-framework-durable-extension/schemas/durable-agent-entity-state.json" }, + "tests": [ + { + "description": "Nonconsumers preserve a separately versioned profile without interpreting owner fields", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": {}, "historyBinding": { "version": 99, "ownerKind": "runtime-specific", "future": [null, false, {}] } } }, + "valid": true + }, + { + "description": "Even malformed profile data passes shared shape validation, not relying-runtime validation", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": {}, "historyBinding": "opaque-unusable-profile" } }, + "valid": true + }, + { + "description": "Retained successful outcome survives payload expiry", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": { "c": { "correlationId": "c", "outcome": "succeeded", "completedAt": "2026-09-10T00:00:00Z", "resultState": "unavailable", "resultUnavailableAt": "2026-09-11T00:00:00Z" } } } }, + "valid": true + }, + { + "description": "Retained failed outcome survives payload expiry", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": { "c": { "correlationId": "c", "outcome": "failed", "completedAt": "2026-09-10T00:00:00Z", "resultState": "unavailable", "resultUnavailableAt": "2026-09-11T00:00:00Z" } } } }, + "valid": true + }, + { + "description": "Legacy receipt without outcome cannot be silently promoted to v2", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": { "c": { "correlationId": "c", "completedAt": "2026-09-10T00:00:00Z", "resultState": "unavailable", "resultUnavailableAt": "2026-09-11T00:00:00Z" } } } }, + "valid": false + }, + { + "description": "Null outcome is not authoritative success or failure", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": { "c": { "correlationId": "c", "outcome": null, "completedAt": "2026-09-10T00:00:00Z", "resultState": "unavailable", "resultUnavailableAt": "2026-09-11T00:00:00Z" } } } }, + "valid": false + }, + { + "description": "No synthetic unknown outcome is introduced into the v2 enum", + "data": { "schemaVersion": "2.0.0", "data": { "conversationHistory": [], "terminalResults": {}, "completionReceipts": { "c": { "correlationId": "c", "outcome": "unknown", "completedAt": "2026-09-10T00:00:00Z", "resultState": "unavailable", "resultUnavailableAt": "2026-09-11T00:00:00Z" } } } }, + "valid": false + } + ] + } +]