diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs index e87f17b..85ab765 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; @@ -10,17 +11,74 @@ namespace Microsoft.Agents.AI.DurableTask; -internal class AgentEntity(IServiceProvider services, CancellationToken cancellationToken = default) : TaskEntity +internal class AgentEntity(IServiceProvider services, CancellationToken cancellationToken = default) : TaskEntity, ITaskEntity { + private static readonly TimeSpan s_minimumResultExpirationSignalDelay = TimeSpan.FromMinutes(1); private readonly IServiceProvider _services = services; private readonly DurableTaskClient _client = services.GetRequiredService(); private readonly ILoggerFactory _loggerFactory = services.GetRequiredService(); private readonly IAgentResponseHandler? _messageHandler = services.GetService(); private readonly DurableAgentsOptions _options = services.GetRequiredService(); + // Entity operations execute once rather than replaying like orchestrations, and + // TaskEntityContext does not expose a deterministic clock. + private readonly TimeProvider _timeProvider = services.GetService() ?? TimeProvider.System; private readonly CancellationToken _cancellationToken = cancellationToken != default ? cancellationToken : services.GetService()?.ApplicationStopping ?? CancellationToken.None; + ValueTask ITaskEntity.RunAsync(TaskEntityOperation operation) + { + if (string.Equals(operation.Name, nameof(CheckAndExpireResults), StringComparison.OrdinalIgnoreCase) && + operation.HasInput) + { + this._cancellationToken.ThrowIfCancellationRequested(); + AgentEntityResultExpirationCheck? check = + (AgentEntityResultExpirationCheck?)operation.GetInput(typeof(AgentEntityResultExpirationCheck)); + // TaskEntity writes State back on every successful dispatch. Bypass it for stale + // signals so a duplicate cannot even rewrite state or initialize a missing entity. + DurableAgentState? state = (DurableAgentState?)operation.State.GetState(typeof(DurableAgentState)); + if (state is null) + { + return new ValueTask((object?)null); + } + + _ = DurableAgentStateSchemaVersion.ParseSupported(state.SchemaVersion); + if (state.SchemaVersion != DurableAgentState.RevisedSchemaVersion) + { + // Preserve legacy validation without dispatching a successful void operation, + // which would call the SDK state setter even though cleanup has no work to do. + ValidateForCommit(state); + return new ValueTask((object?)null); + } + + AgentEntityResultExpirySchedule? schedule = + AgentEntityResultExpirySchedule.Read(state, operation.Context.Id.ToString()); + if (schedule?.Pending is null || schedule.Pending != check) + { + return new ValueTask((object?)null); + } + } + + return this.RunAsync(operation); + } + + protected override DurableAgentState InitializeState(TaskEntityOperation entityOperation) + { + return this._options.EnableMailboxWrites && + 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 +91,99 @@ 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); + ILogger logger = this.GetLogger(sessionId.Name, sessionId.Key); - // Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId} - ILogger logger = this.GetLogger(agent.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)); + } - if (request.Messages.Count == 0) + DateTimeOffset currentTime = this._timeProvider.GetUtcNow(); + DurableAgentRunOutcome existingOutcome; + try + { + existingOutcome = DurableAgentStateOutcomeResolver.Resolve( + this.State, + correlationId, + currentTime); + } + catch (DurableAgentStateCorruptionException exception) { - logger.LogInformation("Ignoring empty request"); - return new AgentResponse(); + logger.LogDurableOutcomeStateCorruption( + exception, + sessionId, + correlationId); + throw; } - this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request)); + 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); + if (this._options.EnableMailboxWrites && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion && + this._options.AuthorizeLegacyMigration?.Invoke(this.State) == true && + existingOutcome.Kind != DurableAgentRunOutcomeKind.CompletedResultUnavailable) + { + // Legacy evidence is converted without constructing or invoking the agent. + DurableAgentState migrated = DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState( + this.State, hasAuthoritativeLegacyHistory: true); + ValidateForCommit(migrated); + this.State = migrated; + } + + 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._options.EnableMailboxWrites) + { + DurableAgentStateContract.ValidateIdentifier(correlationId, "correlationId"); + } + + if (!this._options.EnableMailboxWrites && + this.State.SchemaVersion == DurableAgentState.RevisedSchemaVersion) + { + throw new InvalidOperationException("New mailbox requests require EnableMailboxWrites to be enabled."); + } + + 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._options.EnableMailboxWrites && + this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion && + this._options.AuthorizeLegacyMigration?.Invoke(this.State) == true; + DurableAgentState workingState = migrateLegacy + ? DurableAgentStateOutcomeResolver.PrepareRevisedWorkingState(this.State, hasAuthoritativeLegacyHistory: true) + : this.State.Clone(); + if (this._options.EnableMailboxWrites && + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion) + { + workingState.MailboxWritesAuthorized = true; + // A future/invalid runtime profile cannot be silently replaced after invoking the model. + _ = AgentEntityResultExpirySchedule.Read(workingState, this.Context.Id.ToString()); + } + + workingState.Data.ConversationHistory.Add( + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateRequest.FromRunRequestV2(request, logger) + : DurableAgentStateRequest.FromRunRequest(request, logger)); + AIAgent agent = this.GetAgent(sessionId); + EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services); foreach (ChatMessage msg in request.Messages) { @@ -66,28 +203,44 @@ public async Task Run(RunRequest request) { // 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), + workingState.Data.ConversationHistory.SelectMany(e => e.Messages).Select( + message => workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? message.ToChatMessageV2() + : message.ToChatMessage()), + await agentWrapper.CreateSessionAsync(this._cancellationToken).ConfigureAwait(false), 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 +248,47 @@ 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(); } +#pragma warning disable MEAI001 // Preserve the caller-visible token as well as the mailbox snapshot. + response.ContinuationToken = continuationToken; +#pragma warning restore MEAI001 + // Persist the agent response to the entity state for client polling - this.State.Data.ConversationHistory.Add( - DurableAgentStateResponse.FromResponse(request.CorrelationId, response)); + DurableAgentStateResponse storedResponse = + workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateResponse.FromResponseV2(correlationId, response, logger) + : DurableAgentStateResponse.FromResponse(correlationId, response, logger); + workingState.Data.ConversationHistory.Add(storedResponse); + 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 + { + DurableAgentJsonUtilities.CaptureRetainedLegacyResult(response, storedResponse); + } string responseText = response.Text; @@ -118,42 +303,76 @@ 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? deletionCheckExpiration = this.UpdateExpiration(workingState, sessionId, logger); + this._cancellationToken.ThrowIfCancellationRequested(); + this.CommitWorkingState(workingState, sessionId, logger, deletionCheckExpiration); + + return response; + } + catch (Exception exception) + { + logger.LogDurableAgentExecutionFailed(exception, sessionId); + throw; + } + finally + { + // Clear the current agent context + DurableAgentContext.ClearCurrent(); + } + } + + /// + /// Removes due result payloads while retaining completion receipts, then schedules the next check. + /// + /// + /// Also callable as an explicit entity operation to recover imported states with no scheduled signal. + /// Signals carry no deletion authority: every turn rechecks the current generation and clock. + /// + public void CheckAndExpireResults(AgentEntityResultExpirationCheck? scheduledCheck = null) + { + AgentSessionId sessionId = this.Context.Id; + ILogger logger = this.GetLogger(sessionId.Name, sessionId.Key); + try + { + this._cancellationToken.ThrowIfCancellationRequested(); + _ = DurableAgentStateSchemaVersion.ParseSupported(this.State.SchemaVersion); + if (IsEmptyInitializedState(this.State)) { - DateTime newExpirationTime = DateTime.UtcNow.Add(timeToLive.Value); - bool isFirstInteraction = this.State.Data.ExpirationTimeUtc is null; + // A late signal must not resurrect an entity deleted in the meantime. + this.State = null!; + return; + } - this.State.Data.ExpirationTimeUtc = newExpirationTime; - logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime); + if (this.State.SchemaVersion != DurableAgentState.RevisedSchemaVersion) + { + ValidateForCommit(this.State); + return; + } - // 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); - } + AgentEntityResultExpirySchedule? schedule = + AgentEntityResultExpirySchedule.Read(this.State, this.Context.Id.ToString()); + if (scheduledCheck is not null && (schedule?.Pending is null || schedule.Pending != scheduledCheck)) + { + // Includes old timestamp-only signals, duplicate deliveries and deleted generations. + return; } - else + + if (!this._options.EnableMailboxWrites) { - // 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; - } + throw new InvalidOperationException("Result expiration requires EnableMailboxWrites to be enabled."); } - return response; + // Unlike a new run, cleanup must not assign history identities or promote legacy state. + DurableAgentState workingState = DurableAgentStateJsonConverter.DeserializeRevisedContract( + DurableAgentStateJsonConverter.SerializeRevisedContract(this.State)); + workingState.MailboxWritesAuthorized = true; + this.CommitWorkingState(workingState, sessionId, logger, deletionCheckExpiration: null, + previousResultCheckTime: scheduledCheck?.ScheduledTime); } - finally + catch (Exception exception) { - // Clear the current agent context - DurableAgentContext.ClearCurrent(); + logger.LogDurableAgentExecutionFailed(exception, sessionId); + throw; } } @@ -163,41 +382,89 @@ 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 remove 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) + { + if (expirationTime.HasValue) { - // Entity hasn't expired yet, reschedule the deletion check - TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name); - if (timeToLive.HasValue) - { - this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value); - } + logger.LogTTLExpirationTimeCleared(sessionId); + DurableAgentState workingState = this.State.Clone(); + workingState.Data.ExpirationTimeUtc = null; + ValidateForCommit(workingState); + this.State = workingState; } + + return; + } + + if (!expirationTime.HasValue) + { + return; + } + + if (currentTime >= expirationTime.Value) + { + logger.LogTTLEntityExpired(sessionId, expirationTime.Value); + this.State = null!; + return; } + + // A shorter TTL creates an earlier signal. Its older, later counterpart is stale. + if (scheduledCheck is null || + scheduledCheck.ExpectedExpirationTimeUtc <= expirationTime.Value) + { + this.ScheduleDeletionCheck(sessionId, logger, expirationTime.Value); + } + } + + private static bool IsEmptyInitializedState(DurableAgentState state) + { + 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 void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, TimeSpan timeToLive) + private void ScheduleDeletionCheck( + AgentSessionId sessionId, + ILogger logger, + DateTime expirationTime) { - DateTime currentTime = DateTime.UtcNow; - DateTime expirationTime = this.State.Data.ExpirationTimeUtc ?? currentTime.Add(timeToLive); + 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 +478,131 @@ 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 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. An extension is picked up by the + // existing signal; only a shortened expiration needs a new earlier signal. + return !previousExpirationTime.HasValue || + newExpirationTime < previousExpirationTime.Value + ? newExpirationTime + : null; + } + + private void CommitWorkingState( + DurableAgentState workingState, + AgentSessionId sessionId, + ILogger logger, + DateTime? deletionCheckExpiration, + DateTimeOffset? previousResultCheckTime = null) + { + DateTimeOffset currentTime = this._timeProvider.GetUtcNow(); + DateTimeOffset? nextResultExpiration = null; + AgentEntityResultExpirationCheck? nextSignal = null; + if (workingState.SchemaVersion == DurableAgentState.RevisedSchemaVersion) + { + string entityId = this.Context.Id.ToString(); + AgentEntityResultExpirySchedule? schedule = AgentEntityResultExpirySchedule.Read(workingState, entityId); + foreach (DurableAgentStateTerminalResult result in workingState.Data.TerminalResults!.Values.ToArray()) + { + if (result.ResultExpiresAt is DateTimeOffset expiresAt) + { + if (expiresAt <= currentTime) + { + DurableAgentStateOutcomeResolver.MarkExpiredResultUnavailable( + workingState, result.CorrelationId, currentTime); + } + else if (nextResultExpiration is null || expiresAt < nextResultExpiration) + { + nextResultExpiration = expiresAt; + } + } + } + + AgentEntityResultExpirationCheck? pending = schedule?.Pending; + // Consuming a matching signal rotates its token even if the clock has moved backwards. + // An explicit recovery or successful new run also replaces an overdue/stuck schedule. + if (previousResultCheckTime.HasValue || pending?.ScheduledTime <= currentTime) + { + pending = null; + } + + if (nextResultExpiration is DateTimeOffset resultExpiration) + { + DateTimeOffset schedulingBase = previousResultCheckTime > currentTime + ? previousResultCheckTime.Value + : currentTime; + DateTimeOffset minimumScheduledTime = schedulingBase.Add(s_minimumResultExpirationSignalDelay); + DateTimeOffset scheduledTime = resultExpiration > minimumScheduledTime ? resultExpiration : minimumScheduledTime; + if (pending is null || pending.ScheduledTime > scheduledTime) + { + pending = nextSignal = new AgentEntityResultExpirationCheck( + scheduledTime.ToUniversalTime(), Guid.NewGuid().ToString("N"), entityId); + } + } + else + { + pending = null; + } + + workingState = AgentEntityResultExpirySchedule.Write(workingState, entityId, schedule, pending); + } + + this._cancellationToken.ThrowIfCancellationRequested(); + ValidateForCommit(workingState); + if (deletionCheckExpiration.HasValue) + { + // this.State still points at the hydrated state until the final assignment. + this.ScheduleDeletionCheck(sessionId, logger, deletionCheckExpiration.Value); + } + + if (nextSignal is not null) + { + this.Context.SignalEntity( + this.Context.Id, + nameof(CheckAndExpireResults), + nextSignal, + options: new SignalEntityOptions { SignalTime = nextSignal.ScheduledTime }); + } + + this._cancellationToken.ThrowIfCancellationRequested(); + // This setter performs no synchronous backend I/O. TaskEntity persists the replacement + // and the self-signal outbox only after the operation completes successfully. + 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 +620,7 @@ private ILogger GetLogger(string agentName, string sessionKey) return this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agentName}.{sessionKey}"); } } + +internal sealed record AgentEntityDeletionCheck(DateTime ExpectedExpirationTimeUtc); + +internal sealed record AgentEntityResultExpirationCheck(DateTimeOffset ScheduledTime, string? Token = null, string? EntityId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntityResultExpirySchedule.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntityResultExpirySchedule.cs new file mode 100644 index 0000000..9ae4220 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntityResultExpirySchedule.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; + +namespace Microsoft.Agents.AI.DurableTask; + +/// An optional runtime profile, not part of the shared mailbox schema or history binding. +internal sealed class AgentEntityResultExpirySchedule +{ + internal const string ExtensionName = "Microsoft.Agents.AI.DurableTask.resultExpiry"; + private readonly Dictionary _properties; + + private AgentEntityResultExpirySchedule( + Dictionary properties, + AgentEntityResultExpirationCheck? pending) + { + this._properties = properties; + this.Pending = pending; + } + + public AgentEntityResultExpirationCheck? Pending { get; } + + public static AgentEntityResultExpirySchedule? Read(DurableAgentState state, string entityId) + { + if (state.ExtensionData?.TryGetValue(ExtensionName, out JsonElement profile) != true) + { + return null; + } + + if (profile.ValueKind != JsonValueKind.Object) + { + throw InvalidProfile(); + } + + Dictionary properties = new(StringComparer.Ordinal); + foreach (JsonProperty property in profile.EnumerateObject()) + { + if (!properties.TryAdd(property.Name, property.Value.Clone())) + { + throw InvalidProfile(); + } + } + + if (!properties.TryGetValue("version", out JsonElement version) || + version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out int number) || number != 1 || + !properties.TryGetValue("entityId", out JsonElement identity) || + identity.ValueKind != JsonValueKind.String || identity.GetString() != entityId || + !properties.TryGetValue("scheduledResultExpiryUtc", out JsonElement deadline) || + !properties.TryGetValue("token", out JsonElement token)) + { + throw InvalidProfile(); + } + + AgentEntityResultExpirationCheck? pending = null; + if (deadline.ValueKind != JsonValueKind.Null || token.ValueKind != JsonValueKind.Null) + { + if (deadline.ValueKind != JsonValueKind.String || !deadline.TryGetDateTimeOffset(out DateTimeOffset scheduledTime) || + scheduledTime.Offset != TimeSpan.Zero || + token.ValueKind != JsonValueKind.String || !Guid.TryParseExact(token.GetString(), "N", out Guid generation) || + generation == Guid.Empty) + { + throw InvalidProfile(); + } + + pending = new AgentEntityResultExpirationCheck(scheduledTime, token.GetString(), entityId); + } + + return new AgentEntityResultExpirySchedule(properties, pending); + } + + public static DurableAgentState Write( + DurableAgentState state, + string entityId, + AgentEntityResultExpirySchedule? previous, + AgentEntityResultExpirationCheck? pending) + { + if (previous?.Pending == pending) + { + return state; + } + + using MemoryStream buffer = new(); + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WriteNumber("version", 1); + writer.WriteString("entityId", entityId); + if (pending is null) + { + writer.WriteNull("scheduledResultExpiryUtc"); + writer.WriteNull("token"); + } + else + { + writer.WriteString("scheduledResultExpiryUtc", pending.ScheduledTime); + writer.WriteString("token", pending.Token); + } + + if (previous is not null) + { + foreach ((string key, JsonElement value) in previous._properties) + { + if (key is not ("version" or "entityId" or "scheduledResultExpiryUtc" or "token")) + { + writer.WritePropertyName(key); + value.WriteTo(writer); + } + } + } + + writer.WriteEndObject(); + } + + using JsonDocument profile = JsonDocument.Parse(buffer.ToArray()); + Dictionary extensions = state.ExtensionData is null + ? new(StringComparer.Ordinal) + : new(state.ExtensionData, StringComparer.Ordinal); + extensions[ExtensionName] = profile.RootElement.Clone(); + return new DurableAgentState + { + SchemaVersion = state.SchemaVersion, + MailboxWritesAuthorized = state.MailboxWritesAuthorized, + Data = state.Data, + ExtensionData = extensions, + UnknownProperties = state.UnknownProperties, + }; + } + + private static InvalidOperationException InvalidProfile() => + new($"The '{ExtensionName}' runtime profile is malformed, unsupported, or belongs to another entity. " + + "Result-expiry scheduling cannot safely continue; preserve the profile and use a compatible writer."); +} 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 8ce824e..32e43da 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Hardened durable-agent mailbox delivery, duplicate correlation handling, working-state rollback, token-bounded durable result expiry (including zero-write stale cleanup on legacy state), and stale-safe TTL deletion scheduling; added gated real-backend state/outbox atomicity coverage with locally tested SDK failure handling while keeping schema-2 production activation inaccessible; preserved historical message boundaries, opaque schema-2 history/state profiles, committed failure metadata, and legacy response metadata through mailbox promotion; isolated untrusted response text and malformed activity/child payloads from workflow controls with all-or-nothing typed-message validation, preserved opaque child fallback string routing, and rejected unknown target type hints instead of selecting an unrelated handler ([#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)) 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/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/DurableAgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs index 7670b9e..10e5a0f 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,9 @@ private static JsonSerializerOptions CreateDefaultOptions() // Request Types [JsonSerializable(typeof(RunRequest))] + [JsonSerializable(typeof(AgentEntityDeletionCheck))] + [JsonSerializable(typeof(AgentEntityResultExpirationCheck))] + [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/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/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..337773d 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,7 @@ public sealed class DurableAgentsOptions // Agent names are case-insensitive private readonly Dictionary> _agentFactories = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _agentTimeToLive = 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 +30,66 @@ 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. + /// + /// + /// Under the internal mailbox-writer gate, committed runs schedule entity-local payload cleanup. + /// Physical removal may lag expiry; polling reports unavailable without modifying state. + /// Imported states without a scheduled check need an explicit cleanup operation or a successful new run. + /// + /// 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. @@ -168,10 +230,22 @@ 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) { - return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive; + 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; } /// 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/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs index 077df3b..510586c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -101,6 +101,25 @@ public static partial void LogTTLExpirationTimeCleared( this ILogger logger, AgentSessionId sessionId); + [LoggerMessage( + EventId = 14, + Level = LogLevel.Error, + Message = "[{SessionId}] Durable agent execution failed.")] + public static partial void LogDurableAgentExecutionFailed( + this ILogger logger, + Exception exception, + AgentSessionId sessionId); + + [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); + [LoggerMessage( EventId = 16, Level = LogLevel.Warning, 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..9a7c259 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md @@ -37,6 +37,160 @@ 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; +this draft exposes no public schema-2 activation API. 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). + +Under the internal mailbox-writer gate, a successful new run also removes already-expired mailbox +payloads and maintains one logical entity-local `CheckAndExpireResults` schedule for the earliest +remaining expiry. The optional version-1 runtime profile +`extensionData["Microsoft.Agents.AI.DurableTask.resultExpiry"]` stores the entity identity, UTC scheduled +deadline, and an unpredictable token. It uses the existing root extension map, not a new shared schema +field or `historyBinding`. The replacement state, profile and signal outbox participate in the same +entity operation commit. +Cleanup preserves each completion's outcome, completion/expiry timestamps and unknown receipt metadata, +marks its result unavailable, and records the first cleanup time. It does not delete receipts, invoke +the agent/factory/tools, create a session, or refresh entity TTL. The independent entity-deletion gate +is not required for payload cleanup. + +Entity operations are serialized. New runs reuse a pending check that already covers the earliest +deadline; an earlier deadline replaces it once with a new token. Moving the earliest expiry later reuses +the earlier check, which will schedule one successor when consumed. Superseded physical signals may +still arrive, but only an exact entity/token/deadline match can consume the logical schedule. Stale, +duplicate, timestamp-only pre-profile, and previous-generation signals are no-ops: no state setter, +outgoing signal, model invocation or TTL update. A signal against a deleted entity does not recreate it. +A matching cleanup deep-clones and validates authoritative current state and uses the host's +`TimeProvider`, not the signal, to decide expiry. Consuming it atomically clears or rotates the token. +Early matching checks schedule at most one successor, at least one minute after both the current clock +and the prior scheduled check, avoiding repeated scheduling at the same timestamp when the worker +clock moves backward. Duplicates of that early check cannot advance the chain again. This scheduling floor +is **not** a default retention period. Physical cleanup can lag logical expiry by that floor and scheduler +delivery/clock skew; polling reports unavailability at the recorded expiry without mutating state. +Repeated successful cleanup preserves the original unavailable timestamp. + +Serialization, scheduling, cancellation, and commit failures leave hydrated state unchanged; operation +errors propagate rather than being acknowledged as cleanup. Recovery/retry uses the same +idempotent operation. There is no background entity scan: for an imported state with no signal, or a +signal lost/failed during rollout, the host can explicitly invoke the `CheckAndExpireResults` entity +operation (no input required) on the known entity. One successful turn sweeps its existing due payloads +and installs a missing schedule or supersedes an overdue/stuck token once. A valid future schedule is +reused, so repeated recovery cannot multiply the chain. An early failed check retains its token and +can be retried with the same input, or recovered without input once its persisted deadline is due. +A later successful **new** run also performs this recovery. A host +requiring a cleanup-time bound for otherwise idle imported entities must enqueue that operation as part +of import/recovery and monitor failed operations. Read-only polling and terminal duplicate calls do not +provide durable cleanup. Cleanup never migrates legacy state and rejects mailbox writes while the gate +is off. + +Malformed/unsupported scheduling profiles fail closed before a new model invocation or cleanup. +Other extensions and unknown fields in a supported profile are preserved. Non-relying result reads +remain opaque. Compatible writers must preserve this profile and honor its scheduling contract; +preserving unknown JSON alone does not make an older scheduler safe to deploy alongside this writer. + +**Merge and release gate:** this draft must not merge or release until the actual real-backend atomicity +test passes in an explicitly isolated environment. A clearly documented gated skip is acceptable only +for draft readiness, not for merge or release. Schema-2 production writing remains inaccessible in this +layer, even when public +`ResultRetentionPeriod` is set. Activation requires coordinated reader/writer/rollback compatibility, +late-duplicate/deletion policy agreement, and an executed real-backend atomicity test in an explicitly +isolated environment. The [gated integration test](../../tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/README.md) +checks state plus outgoing delayed signals, worker restart, duplicate delivery and a failure after +state/outbox staging. A skipped test or passing mock tests **does not verify backend atomicity** and +does not satisfy either the merge gate or the release gate. + +`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. +When complete legacy history is independently authorized for promotion, the retained mailbox snapshot +also preserves declared response extension data and unknown response fields independently of the +transcript. First delivery, cold polling, and repeated duplicates retain that canonical metadata; +JSON-looking response text never supplies a missing canonical `value`. + +## 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. +Each trusted activity or child-workflow `sentMessages` entry must contain a nonblank string `typeName` +and `data`; null entries or missing/null/empty/whitespace fields reject the **whole** collection before +any routing. Activity JSON also rejects ambiguous repeated known fields and invalid field kinds. +An invalid child collection discards all its messages, events and halt controls, matching the +all-or-nothing activity trust boundary. Its exact `Result` text, not the serialized invalid envelope, +is the fallback; it is never decoded again as controls. Child shared state is always isolated. +These are CLR string fields: serialized JSON payloads such as `null`, `false`, `0`, and `""` remain valid +inside the `data` string. Payload text is not recursively interpreted as controls or required to resolve +a runtime type during envelope validation. Unknown fields cannot override known controls. A nonblank +unknown type name is structurally valid and travels unchanged to the target activity. If that activity +cannot resolve it or match a registered input type by name, it fails rather than choosing the first +handler. Type resolution is not added to orchestration code. Existing registered-name/version matching, +untyped input, and supported string/string-array adaptation remain unchanged. +The child runner tags its non-empty final result as a CLR string when routing it to parent successors, +so an executor supporting several input types receives the original text through its string handler +even when another supported type is listed first. Child result-only fallback (including legacy missing, +null or empty message collections) also retains string provenance. Whitespace-only `Result` text is +preserved exactly, even though a whitespace-only typed `data` field is invalid. Null/empty results do +not enqueue a fallback message. Legacy absent collections preserve trusted events/halt; invalid entries +discard those controls. Valid typed child halt requests and superstep limits are unchanged. + +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. + ## 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/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 9bb1770..0f44cfd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -35,6 +35,11 @@ internal sealed class DurableAgentState [JsonPropertyName("schemaVersion")] 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. /// @@ -53,18 +58,14 @@ internal sealed class DurableAgentState /// public DurableAgentState Clone() { - byte[] serialized = JsonSerializer.SerializeToUtf8Bytes( - this, - DurableAgentStateJsonContext.Default.DurableAgentState); - DurableAgentState clone = JsonSerializer.Deserialize( - serialized, - DurableAgentStateJsonContext.Default.DurableAgentState) - ?? throw new JsonException("The durable agent state could not be cloned."); + 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, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index c13e634..062477f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -27,7 +27,15 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options) { - WriteValue(writer, value, allowRevisedSchema: false); + WriteValue(writer, value, allowRevisedSchema: value.MailboxWritesAuthorized); } private static void WriteValue( @@ -146,14 +154,21 @@ private static void WriteValue( 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); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index 1bea283..d12d216 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -134,7 +134,21 @@ role is not ("user" or "assistant" or "system" or "tool")) /// Converts this to a . /// /// A representing this message. - public ChatMessage ToChatMessage() + public ChatMessage ToChatMessage() => this.ToChatMessage(static content => content.ToAIContent()); + + /// + /// Projects shared schema-2 content without inventing native representations for opaque shapes. + /// + internal ChatMessage ToChatMessageV2() => this.ToChatMessage(static content => + content is DurableAgentStateUriContent { MediaType: null } + ? new DurableAgentStateUnknownContent + { + Content = JsonSerializer.SerializeToElement( + content, DurableAgentStateJsonContext.Default.DurableAgentStateContent), + }.ToAIContent() + : content.ToAIContent()); + + private ChatMessage ToChatMessage(Func convertContent) { AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null ? null @@ -148,7 +162,7 @@ public ChatMessage ToChatMessage() AuthorName = this.AuthorName, MessageId = this.MessageId, AdditionalProperties = additionalProperties, - Contents = this.Contents.Select(c => c.ToAIContent()).ToList(), + Contents = this.Contents.Select(convertContent).ToList(), Role = new(this.Role) }; } 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..c468483 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateOutcomeResolver.cs @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +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(static message => message.ToChatMessageV2()) ?? + 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 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, + AdditionalProperties = response.ExtensionData, + UnknownProperties = response.UnknownProperties, + }; + // Preserve opaque legacy response/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/DurableAgentStateRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs index 863e7fa..d68cd58 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -49,6 +49,17 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry public static DurableAgentStateRequest FromRunRequest( RunRequest request, ILogger? logger = null) + => FromRunRequest(request, allowLosslessV2: false, logger); + + internal static DurableAgentStateRequest FromRunRequestV2( + RunRequest request, + ILogger? logger = null) + => FromRunRequest(request, allowLosslessV2: true, logger); + + private static DurableAgentStateRequest FromRunRequest( + RunRequest request, + bool allowLosslessV2, + ILogger? logger) { DateTimeOffset createdAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow; return new DurableAgentStateRequest() @@ -56,14 +67,17 @@ public static DurableAgentStateRequest FromRunRequest( CorrelationId = request.CorrelationId, OrchestrationId = request.OrchestrationId, Messages = request.Messages.Select( - (message, index) => DurableAgentStateMessage.FromChatMessage( - message, - DurableAgentStateMessageIdentity.Create( + (message, index) => + { + string messageId = DurableAgentStateMessageIdentity.Create( "request", request.CorrelationId, createdAt, - index), - logger)).ToList(), + 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 5114953..ca164ff 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -29,6 +29,19 @@ 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); @@ -36,7 +49,7 @@ public static DurableAgentStateResponse FromResponse( { CorrelationId = correlationId, CreatedAt = createdAt, - Messages = CreateStoredMessages(messages, correlationId, createdAt, logger), + Messages = CreateStoredMessages(messages, correlationId, createdAt, logger, allowLosslessV2), Usage = DurableAgentStateUsage.FromUsage(response.Usage) }; } @@ -77,17 +90,21 @@ private static List CreateStoredMessages( IEnumerable messages, string correlationId, DateTimeOffset createdAt, - ILogger? logger) + ILogger? logger, + bool allowLosslessV2 = false) { return messages - .Select((message, storedIndex) => DurableAgentStateMessage.FromChatMessage( - message, - DurableAgentStateMessageIdentity.Create( + .Select((message, storedIndex) => + { + string messageId = DurableAgentStateMessageIdentity.Create( "response", correlationId, createdAt, - storedIndex), - logger)) + storedIndex); + return allowLosslessV2 + ? DurableAgentStateMessage.FromTerminalChatMessage(message, messageId, logger) + : DurableAgentStateMessage.FromChatMessage(message, messageId, logger); + }) .ToList(); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs index 2c18bcb..903db90 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs @@ -114,7 +114,9 @@ public static DurableAgentStateTerminalResponse FromResponse( }; } - public AgentResponse ToResponse() + public AgentResponse ToResponse() => this.ToResponse(static message => message.ToChatMessage()); + + internal AgentResponse ToResponse(Func messageConverter) { AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null ? null @@ -123,7 +125,7 @@ public AgentResponse ToResponse() return new AgentResponse { - Messages = this.Messages.Select(message => message.ToChatMessage()).ToList(), + Messages = this.Messages.Select(messageConverter).ToList(), Usage = this.Usage?.ToUsageDetails(), CreatedAt = this.CreatedAt, ResponseId = this.ResponseId, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 07335cd..7bf208e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -26,11 +26,25 @@ Schema version 1.2 adds optional message identity and extension metadata, opaque `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. Wiring that path into entity execution is -deferred. New +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. +Schema-2 history and terminal delivery share an explicit message projection. A shared URI without +`mediaType` is opaque `AIContent` carrying its canonical JSON, including unknown content properties; +no media type is invented. Normal URI content remains native `UriContent`. Message roles, IDs, authors, +timestamps and additional properties survive projection, and canonical message/response JSON retains +unknown metadata that the native types cannot express. Cold next-invocation history uses this projection, +not the stricter legacy conversion. + 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 @@ -43,17 +57,21 @@ projected to `UsageDetails`, only integral numeric extension values representabl 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. -This layer defines and round-trips the schema contracts only. Agent entity integration for session ownership, -replay filtering, compaction, retention, and provider behavior is deferred to later stack layers. +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 and provisional history-binding contracts use schema `2.0.0`. This is intentionally a fail-closed major +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 production .NET reader -and writer reject 2.0 until mailbox-aware behavior is activated, and new state continues to default to `1.2.0`. -An explicit internal passive contract path exists only for serializer/fixture tests and later deliberate -activation. A later execution layer must opt into `2.0.0` only when it implements the complete mailbox layout. +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. @@ -63,21 +81,78 @@ it does not distinguish an accepted pending request from an unknown identity. Op 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. +With the internal mailbox-writer gate enabled, successful new runs and the scheduled/explicit +`CheckAndExpireResults` entity operation durably remove due `terminalResults` entries and transition +their existing receipts to `unavailable`. The operation uses an independent state copy and commits its +next self-signal with that state; it does not refresh whole-entity TTL or require the independent deletion +gate. Expiry is rechecked against current state and `TimeProvider` on every serialized turn; stale signals +are not authority to remove a newer result. Polling is read-only. Imported/idle states without a scheduling +chain require an explicit cleanup operation or a later successful new run; there is no automatic scan. +See the [cleanup, retry, and scheduling limits](../README.md#durable-completion-and-delivery). + +### Optional result-expiry runtime extension + +This writer uses the existing root `extensionData` map for its scheduling protocol. No schema DTO or +`schemas/` field is added. `historyBinding` remains opaque and is not an ownership/scheduling profile. + +```json +{ + "extensionData": { + "Microsoft.Agents.AI.DurableTask.resultExpiry": { + "version": 1, + "entityId": "@dafx-agent@session", + "scheduledResultExpiryUtc": "2026-09-12T00:20:00+00:00", + "token": "5b9ddf23b2d94e42b1dd4d946134f043" + } + } +} +``` + +This fragment illustrates extension metadata, not a complete mailbox state. `scheduledResultExpiryUtc` +is the scheduled check time (including backoff), not result-retention authority. The self-signal carries +`scheduledTime`, `token`, and `entityId`; all must match the persisted pending check. A fresh random GUID +token per schedule invalidates duplicate deliveries, superseded deadlines, and old entity generations. +When no expiring payload remains, the deadline and token become explicit null together. Other profile +fields and other extensions are retained. A missing profile is compatible for import/first use: +a successful new run or explicit no-input cleanup installs it when needed. Old timestamp-only signals +cannot install or consume a profile; import/upgrade recovery must explicitly enqueue no-input cleanup. -These DTOs and converters are passive contracts. Delivery lookup and polling, binding selection and enforcement, -result expiry, and transcript retention are implemented by later stack layers. +Relying writers validate version, entity identity, UTC deadline, nonempty GUID token, paired nulls and +duplicate properties, failing closed rather than dropping an unsupported/malformed profile. A new run +validates before constructing the model. Legacy writes do not interpret/create this profile, and +non-relying reads preserve it opaquely. Compatible serializers must preserve the extension; compatible +schedulers must also honor the token contract. Do not roll back to an older scheduler that merely +preserves the extension but starts a fresh chain on every run. -When those layers activate schema 2.0, one successful durable entity operation must atomically commit the +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. This layer validates persisted shape -and consistency but performs no commit or lookup behavior. +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 majors and defaults new writes to 1.2. Other runtimes require coordinated version +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. +Production activation remains inaccessible through public options in this layer. An actual isolated +backend/worker run proving state-and-outbox rollback, restart and duplicate dispatch is a mandatory +**merge and release gate**, not a claim established by mock-only coverage or a skipped integration test. +A clearly documented gated skip permits draft readiness only: do not merge or release this correction +until the actual isolated-backend test passes. See the +[integration safety and execution instructions](../../../tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/README.md). ## Sample State diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs index 116c6aa..20d20f8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs @@ -175,6 +175,8 @@ internal static Type ResolveInputType(string? inputTypeName, ISet supporte } } - return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string); + // An explicit unknown hint is not untyped input. Do not reinterpret its data as + // the first registered handler's type; fail at the activity boundary instead. + return loadedType ?? throw new InvalidOperationException($"Input type '{inputTypeName}' could not be resolved for this executor."); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index 4844702..982f117 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,27 @@ 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 }; + } + + if (workflowResult.SentMessages is not null && + !DurableExecutorOutput.HasValidTypedMessages(workflowResult.SentMessages)) + { + // Match activity-envelope rejection: no partial messages, events or halt controls. + // Result is already text; never parse it again as an executor control envelope. + return new DurableExecutorOutput + { + Result = workflowResult.Result, + SentMessages = CreateResultMessages(workflowResult.Result), + }; } // Propagate the result, events, and sent messages from the sub-workflow. @@ -225,14 +220,21 @@ 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 ?? [], + SentMessages = workflowResult.SentMessages is { Count: > 0 } + ? workflowResult.SentMessages + : CreateResultMessages(workflowResult.Result), HaltRequested = workflowResult.HaltRequested, }; - - return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); } + + private static List CreateResultMessages(string? result) => + // Result-only fallback retains exact string provenance, including whitespace text. + // Unlike a received typed payload, it is not an envelope field to validate or deserialize. + !string.IsNullOrEmpty(result) + ? [new TypedPayload { TypeName = typeof(string).AssemblyQualifiedName, Data = result }] + : []; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs index ce3f26c..aa14656 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,124 @@ 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) && + (output.SentMessages is null || HasValidTypedMessages(output.SentMessages))) + { + bool validMessages = true; + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + if (property.Name.Equals("sentMessages", StringComparison.OrdinalIgnoreCase)) + { + validMessages = property.Value.EnumerateArray().All(HasValidTypedMessage); + } + } + + 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 HasValidTypedMessage(JsonElement message) + { + return HasUnambiguousProperties(message, s_messageProperties, out HashSet presentProperties) && + presentProperties.Contains(nameof(TypedPayload.TypeName)) && + presentProperties.Contains(nameof(TypedPayload.Data)); + } + + /// + /// Validates the entire typed collection before activity or sub-workflow controls are accepted. + /// + internal static bool HasValidTypedMessages(List messages) + { + // Both fields are CLR strings. JSON null/false/0/"" payloads are serialized *inside* + // Data, not supplied as null/scalar envelope fields. Do not parse or reinterpret that text. + return messages.All(message => message is not null && + !string.IsNullOrWhiteSpace(message.TypeName) && !string.IsNullOrWhiteSpace(message.Data)); + } + + 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..dad94bb 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); @@ -229,7 +229,7 @@ private static async Task RunSuperstepLoopAsync( Result = finalResult, Events = state.AccumulatedEvents, SentMessages = !string.IsNullOrEmpty(finalResult) - ? [new TypedPayload { Data = finalResult }] + ? [new TypedPayload { Data = finalResult, TypeName = typeof(string).AssemblyQualifiedName }] : [], HaltRequested = haltRequested }; @@ -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.IntegrationTests/README.md b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/README.md new file mode 100644 index 0000000..1fc0827 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/README.md @@ -0,0 +1,83 @@ +# Result-expiry backend atomicity merge and release gate + +`ResultExpiryAtomicityTests.StateAndOutboxSurviveRestartAndRollbackFailedCleanupAsync` uses +`ConfigureDurableAgents`, the actual Durable Task Scheduler client and worker, and the production +`AgentEntity`. Its deterministic local `AIAgent` needs no LLM, Foundry or cloud credentials. + +**Not run by default.** Missing explicit opt-in or endpoint produces an xUnit **skip**, not a passing +early return. This repository's ordinary integration helpers may fall back to shared localhost:8080; +this test never does. It does not invoke Docker, provision resources, discover credentials, or stop +other workers/containers. Only its own hosts are stopped/disposed. + +Before enabling, independently arrange and verify an **existing isolated emulator** on a non-default +HTTP loopback port. Do not enable against a shared/fixed emulator or cloud service. Each invocation +generates a fresh `expiry-{32-character GUID}` task hub and entity key; only its own worker restart +reuses that hub. The test does not delete shared hubs or containers. + +From `dotnet`, after a Release build, explicitly opt in for this process: + +```powershell +$env:DURABLE_AGENT_EXPIRY_INTEGRATION = '1' +$env:DURABLE_AGENT_EXPIRY_EMULATOR_ENDPOINT = 'http://127.0.0.1:' +dotnet tests\Microsoft.Agents.AI.DurableTask.IntegrationTests\bin\Release\net10.0\Microsoft.Agents.AI.DurableTask.IntegrationTests.dll --filter-class '*ResultExpiryAtomicityTests' --timeout 5m +``` + +The placeholder is intentional; there is no suggested shared port. Opt-in with an invalid endpoint +fails before host construction. The connection uses `Authentication=None` and the fresh test hub. +Allow about 2–3 minutes (four-minute cancellation bound): the test deliberately waits for actual +delayed backend delivery rather than treating intercepted signals as proof of an outbox commit. + +## Assertions and observability + +1. Two successful runs persist two mailbox results/receipts but stage exactly one delayed signal. +2. A cleanup operation stages a payload sweep, a token replacement and one successor, then a test + decorator throws **after** the real `AgentEntity` has staged state and signals. A real orchestration + catches the SDK's `EntityOperationFailedException` only for the injected cleanup marker + (matching entity, operation, error type and message, without an inner failure). Unrelated failures + propagate. Backend state must equal its pre-operation serialization. +3. The first host is stopped/disposed; a new worker/client starts against the same unique hub. + The original delayed backend signal (not another client signal) must sweep the first result and + commit exactly one successor. +4. Three explicitly duplicated deliveries through real entity calls must produce zero state setters + and zero outgoing signals, with identical persisted state. Unit tests separately check **100 runs** + and **100 duplicate deliveries**, asserting exact counts on each step. +5. The committed successor must arrive automatically and remove the remaining payload while retaining + both receipts. A ten-second observation window beyond both successor deadlines must contain **zero** + deliveries of the token staged by the failed operation, even if such a leaked signal would be stale. + Expected totals: eight dispatches, two model calls, three attempted outgoing signals (one rolled + back, two committed). The observation window is bounded evidence, not a guarantee about arbitrary + future backend delays. + +The observer forwards every state/context operation to the real runtime object; it is not a mocked +entity operation or backend. `ConfigureDurableAgents` configures the production agent services/options +and real Scheduler client. The worker is registered separately using public `AddDurableTaskWorker`, +`AddTasks`, and `DurableTaskRegistry.AddEntity(factory)` APIs. That test-only factory constructs the +actual production `AgentEntity` through the existing friend-assembly access, then wraps it in the +forwarding `ITaskEntity` observer. No private SDK reflection or entity-registry replacement is used. +The actual AgentEntity executes against real SDK operation/context/state objects and the real +worker/backend outbox; there is no mock fallback. + +## Local orchestration regression + +`ResultExpiryOrchestrationTests` executes the same test-orchestration handler without a backend. +Only this unit proof substitutes the entity call. It uses the actual SDK exception and JSON-reloaded +`TaskFailureDetails` from the injected exception: success returns `true`, the expected injected cleanup +failure returns `false`, and mismatched entity/operation/type/message/inner failures, ordinary task +failures and cancellation propagate. It is not gated and is separate from the actual backend Fact: + +```powershell +dotnet tests\Microsoft.Agents.AI.DurableTask.IntegrationTests\bin\Release\net10.0\Microsoft.Agents.AI.DurableTask.IntegrationTests.dll --filter-class '*ResultExpiryOrchestrationTests' --timeout 2m +``` + +Passing this local regression proves exception handling, not backend rollback or outbox atomicity. + +## Rollout restriction + +On a machine without the explicit isolated-backend configuration, report this test as **NOT RUN +(gated skip)**. A compiled/skipped test and passing unit mocks do **not** establish backend atomicity. +An executed passing isolated run is mandatory before **either merge or release**. A clearly documented +gated skip is acceptable for **draft readiness only**; do not merge or release this correction until +the real-backend test passes. Independent review acceptance does not waive this requirement. +Shared schema/reader/writer/rollback agreement and late-duplicate/deletion policy remain additional +rollout gates. Schema-2 writing/deletion remain internal test-only, +default-off and inaccessible to production public options in this layer. diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ResultExpiryAtomicityTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ResultExpiryAtomicityTests.cs new file mode 100644 index 0000000..928d7af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ResultExpiryAtomicityTests.cs @@ -0,0 +1,403 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// Uses a real worker and backend; the forwarding decorator only observes and injects a failure. +[Collection("Sequential")] +[Trait("Category", "Integration")] +public sealed class ResultExpiryAtomicityTests(ITestOutputHelper output) +{ + private const string AgentName = "ExpiryProbe"; + private const string CallOrchestration = "ExpiryCall"; + + [IsolatedExpiryBackendFact] + public async Task StateAndOutboxSurviveRestartAndRollbackFailedCleanupAsync() + { + // There is deliberately no default connection, cloud authentication or container startup. + string endpoint = Environment.GetEnvironmentVariable("DURABLE_AGENT_EXPIRY_EMULATOR_ENDPOINT")!; + Assert.True(Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) && + uri.Scheme == "http" && uri.IsLoopback && uri.Port != 8080 && + uri.AbsolutePath == "/" && uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0, + "Supply an explicitly isolated HTTP loopback emulator endpoint on a non-default port."); + string hub = $"expiry-{Guid.NewGuid():N}"; + string connection = $"Endpoint={endpoint};TaskHub={hub};Authentication=None"; + using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(4)); + CancellationToken cancellation = timeout.Token; + DateTimeOffset start = DateTimeOffset.UtcNow; + Clock clock = new(start); + Probe probe = new(); + LocalAgent agent = new(probe); + EntityInstanceId id = new(AgentSessionId.ToEntityName(AgentName), Guid.NewGuid().ToString("N")); + AgentEntityResultExpirationCheck original; + AgentEntityResultExpirationCheck failedSuccessor; + DurableAgentState committed; + + using (IHost first = await StartAsync(connection, clock, agent, probe, cancellation)) + { + DurableTaskClient client = first.Services.GetRequiredService(); + Assert.True(await CallAsync(client, id, nameof(AgentEntity.Run), + JsonSerializer.SerializeToElement(new RunRequest("first") { CorrelationId = "first" }), cancellation)); + committed = await ReadAsync(client, id, cancellation); + original = Pending(committed, id)!; + Assert.NotNull(original); + Assert.Single(probe.Dispatches); + Assert.Equal(original, Assert.Single(probe.Dispatches.Single().Signals)); + Assert.Equal(1, probe.Dispatches.Single().Writes); + + first.Services.GetRequiredService().ResultRetentionPeriod = TimeSpan.FromSeconds(2); + Assert.True(await CallAsync(client, id, nameof(AgentEntity.Run), + JsonSerializer.SerializeToElement(new RunRequest("future") { CorrelationId = "future" }), cancellation)); + committed = await ReadAsync(client, id, cancellation); + Assert.Equal(2, committed.Data.TerminalResults!.Count); + Assert.Equal(original, Pending(committed, id)); + Assert.Equal(2, probe.Dispatches.Count); + Assert.Empty(probe.Dispatches.Last().Signals); + Assert.Equal(2, probe.ModelCalls); + string beforeFailure = Serialize(committed); + + clock.UtcNow = start.AddSeconds(1); + probe.FailNextCleanup = 1; + Assert.False(await CallAsync(client, id, nameof(AgentEntity.CheckAndExpireResults), + JsonSerializer.SerializeToElement(original), cancellation)); + Dispatch failed = probe.Dispatches.Last(); + Assert.True(failed.Failed); + Assert.Equal(1, failed.Writes); + failedSuccessor = Assert.Single(failed.Signals); + Assert.NotEqual(original.Token, failedSuccessor.Token); + Assert.Equal(beforeFailure, Serialize(await ReadAsync(client, id, cancellation))); + Assert.Equal(2, probe.ModelCalls); + await first.StopAsync(cancellation); + } + + using IHost restarted = await StartAsync(connection, clock, agent, probe, cancellation); + DurableTaskClient restartedClient = restarted.Services.GetRequiredService(); + Assert.Equal(Serialize(committed), Serialize(await ReadAsync(restartedClient, id, cancellation))); + + // No client signal is sent here: only the first run's committed delayed outbox can wake cleanup. + DurableAgentState cleaned = await WaitForStateAsync(restartedClient, id, + state => state.Data.CompletionReceipts!["first"].ResultState == "unavailable", cancellation); + Assert.Equal("future", Assert.Single(cleaned.Data.TerminalResults!).Key); + AgentEntityResultExpirationCheck successor = Pending(cleaned, id)!; + Assert.NotNull(successor); + Assert.NotEqual(failedSuccessor.Token, successor.Token); + Dispatch consumed = Assert.Single(probe.Dispatches, dispatch => dispatch.Input == original && !dispatch.Failed); + Assert.Equal(successor, Assert.Single(consumed.Signals)); + Assert.Equal(1, consumed.Writes); + Assert.Equal(4, probe.Dispatches.Count); + Assert.Equal(3, probe.Dispatches.Sum(dispatch => dispatch.Signals.Count)); // Includes the rolled-back attempt. + + for (int duplicate = 0; duplicate < 3; duplicate++) + { + Assert.True(await CallAsync(restartedClient, id, nameof(AgentEntity.CheckAndExpireResults), + JsonSerializer.SerializeToElement(original), cancellation)); + Dispatch ignored = probe.Dispatches.Last(); + Assert.Equal(0, ignored.Writes); + Assert.Empty(ignored.Signals); + Assert.Equal(5 + duplicate, probe.Dispatches.Count); + Assert.Equal(Serialize(cleaned), Serialize(await ReadAsync(restartedClient, id, cancellation))); + } + + clock.UtcNow = start.AddSeconds(3); + DurableAgentState finished = await WaitForStateAsync(restartedClient, id, + state => state.Data.TerminalResults!.Count == 0, cancellation); + Assert.Null(Pending(finished, id)); + Assert.Equal(2, finished.Data.CompletionReceipts!.Count); + Assert.All(finished.Data.CompletionReceipts.Values, receipt => Assert.Equal("unavailable", receipt.ResultState)); + Dispatch finalCleanup = Assert.Single(probe.Dispatches, dispatch => dispatch.Input == successor); + Assert.Equal(1, finalCleanup.Writes); + Assert.Empty(finalCleanup.Signals); + + // Observe beyond both scheduled deadlines to detect a leaked outbox from the failed operation, + // even if its stale token would cause no visible state mutation. + DateTimeOffset observationEnd = (successor.ScheduledTime > failedSuccessor.ScheduledTime + ? successor.ScheduledTime : failedSuccessor.ScheduledTime).AddSeconds(10); + TimeSpan remaining = observationEnd - DateTimeOffset.UtcNow; + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining, cancellation); + } + + Assert.DoesNotContain(probe.Dispatches, dispatch => dispatch.Input == failedSuccessor); + Assert.Equal(8, probe.Dispatches.Count); + Assert.Equal(3, probe.Dispatches.Sum(dispatch => dispatch.Signals.Count)); + Assert.Equal(2, probe.ModelCalls); + Assert.Equal(Serialize(finished), Serialize(await ReadAsync(restartedClient, id, cancellation))); + await restarted.StopAsync(cancellation); + output.WriteLine("Real isolated backend: 2 runs, 1 failed cleanup, restart, 2 automatic cleanups, " + + "3 duplicates; 3 staged signals including 1 rolled back; 0 deliveries of rolled-back token."); + } + + private static async Task StartAsync( + string connection, Clock clock, LocalAgent agent, Probe probe, CancellationToken cancellation) + { + IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddSingleton(clock); + services.ConfigureDurableAgents(options => + { + options.EnableMailboxWrites = true; + options.DefaultTimeToLive = null; + options.ResultRetentionPeriod = TimeSpan.FromSeconds(1); + options.AddAIAgent(agent); + }, + clientBuilder: builder => builder.UseDurableTaskScheduler(connection)); + services.AddDurableTaskWorker(builder => + { + builder.UseDurableTaskScheduler(connection); + builder.AddTasks(registry => + { + // Use the public registry to observe the actual AgentEntity, not private SDK metadata. + registry.AddEntity(AgentSessionId.ToEntityName(AgentName), + provider => new ObservedEntity(new AgentEntity(provider), probe)); + registry.AddOrchestratorFunc(CallOrchestration, (context, command) => + InvokeEntityAsync(context.Entities, + new EntityInstanceId(AgentSessionId.ToEntityName(AgentName), command.Key), + command.Operation, command.Input)); + }); + }); + }) + .Build(); + try + { + await host.StartAsync(cancellation); + return host; + } + catch + { + host.Dispose(); + throw; + } + } + + internal static async Task InvokeEntityAsync( + TaskOrchestrationEntityFeature entities, EntityInstanceId id, string operation, JsonElement input) + { + try + { + await entities.CallEntityAsync(id, operation, input); + return true; + } + catch (EntityOperationFailedException exception) when ( + operation == nameof(AgentEntity.CheckAndExpireResults) && + exception.EntityId == id && + exception.OperationName == operation && + exception.FailureDetails.ErrorType == typeof(InjectedCleanupFailureException).FullName && + exception.FailureDetails.ErrorMessage == InjectedCleanupFailureException.FailureMessage && + exception.FailureDetails.InnerFailure is null) + { + return false; + } + } + + internal sealed class InjectedCleanupFailureException : Exception + { + internal const string FailureMessage = "Injected failure after real state/outbox staging."; + + public InjectedCleanupFailureException() : base(FailureMessage) + { + } + + public InjectedCleanupFailureException(string? message) : base(message) + { + } + + public InjectedCleanupFailureException(string? message, Exception? innerException) : base(message, innerException) + { + } + } + + private static async Task CallAsync( + DurableTaskClient client, EntityInstanceId id, string operation, JsonElement input, CancellationToken cancellation) + { + string instance = await client.ScheduleNewOrchestrationInstanceAsync( + CallOrchestration, new Command(id.Key, operation, input), cancellation); + OrchestrationMetadata completion = await client.WaitForInstanceCompletionAsync(instance, true, cancellation); + Assert.Equal(OrchestrationRuntimeStatus.Completed, completion.RuntimeStatus); + return completion.ReadOutputAs(); + } + + private static async Task ReadAsync( + DurableTaskClient client, EntityInstanceId id, CancellationToken cancellation) + { + EntityMetadata? metadata = await client.Entities.GetEntityAsync(id, true, cancellation); + Assert.NotNull(metadata); + return metadata.State.ReadAs(); + } + + private static async Task WaitForStateAsync( + DurableTaskClient client, EntityInstanceId id, Func predicate, CancellationToken cancellation) + { + while (true) + { + DurableAgentState state = await ReadAsync(client, id, cancellation); + if (predicate(state)) + { + return state; + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellation); + } + } + + private static AgentEntityResultExpirationCheck? Pending(DurableAgentState state, EntityInstanceId id) => + AgentEntityResultExpirySchedule.Read(state, id.ToString())?.Pending; + + private static string Serialize(DurableAgentState state) => + JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + private sealed record Command(string Key, string Operation, JsonElement Input); + + private sealed class Clock(DateTimeOffset now) : TimeProvider + { + private long _ticks = now.UtcTicks; + + public DateTimeOffset UtcNow + { + get => new(Interlocked.Read(ref this._ticks), TimeSpan.Zero); + set => Interlocked.Exchange(ref this._ticks, value.UtcTicks); + } + + public override DateTimeOffset GetUtcNow() => this.UtcNow; + } + + private sealed class Probe + { + public ConcurrentQueue Dispatches { get; } = new(); + public int FailNextCleanup; + public int ModelCalls; + } + + private sealed class Dispatch + { + public AgentEntityResultExpirationCheck? Input { get; init; } + public List Signals { get; } = []; + public int Writes { get; set; } + public bool Failed { get; set; } + } + + private sealed class ObservedEntity(ITaskEntity inner, Probe probe) : ITaskEntity + { + public async ValueTask RunAsync(TaskEntityOperation operation) + { + bool cleanup = operation.Name == nameof(AgentEntity.CheckAndExpireResults); + Dispatch dispatch = new() + { + Input = cleanup ? operation.GetInput() : null, + }; + try + { + object? result = await inner.RunAsync(new ObservedOperation(operation, dispatch)); + if (cleanup && Interlocked.Exchange(ref probe.FailNextCleanup, 0) != 0) + { + // Deliberately fail AFTER the real AgentEntity stages replacement state and signals. + throw new InjectedCleanupFailureException(); + } + + return result; + } + catch + { + dispatch.Failed = true; + throw; + } + finally + { + probe.Dispatches.Enqueue(dispatch); + } + } + } + + private sealed class ObservedOperation(TaskEntityOperation inner, Dispatch dispatch) : TaskEntityOperation + { + public override TaskEntityContext Context { get; } = new ObservedContext(inner.Context, dispatch); + public override TaskEntityState State { get; } = new ObservedState(inner.State, dispatch); + public override string Name => inner.Name; + public override bool HasInput => inner.HasInput; + public override object? GetInput(Type inputType) => inner.GetInput(inputType); + } + + private sealed class ObservedState(TaskEntityState inner, Dispatch dispatch) : TaskEntityState + { + public override bool HasState => inner.HasState; + public override object? GetState(Type type) => inner.GetState(type); + public override void SetState(object? state) + { + dispatch.Writes++; + inner.SetState(state); + } + } + + private sealed class ObservedContext(TaskEntityContext inner, Dispatch dispatch) : TaskEntityContext + { + public override EntityInstanceId Id => inner.Id; + public override string ScheduleNewOrchestration(TaskName name, object? input = null, StartOrchestrationOptions? options = null) => + inner.ScheduleNewOrchestration(name, input, options); + + public override void SignalEntity(EntityInstanceId id, string operationName, object? input = null, SignalEntityOptions? options = null) + { + if (operationName == nameof(AgentEntity.CheckAndExpireResults)) + { + dispatch.Signals.Add(Assert.IsType(input)); + } + + inner.SignalEntity(id, operationName, input, options); + } + } + + private sealed class LocalAgent(Probe probe) : AIAgent + { + public override string Name => AgentName; + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new LocalSession()); + 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 LocalSession()); + 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) + { + Interlocked.Increment(ref probe.ModelCalls); + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, "local response"); + } + + private sealed class LocalSession : AgentSession; + } + + private sealed class IsolatedExpiryBackendFactAttribute : FactAttribute + { + public IsolatedExpiryBackendFactAttribute( + [System.Runtime.CompilerServices.CallerFilePath] string? sourceFilePath = null, + [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) + : base(sourceFilePath, sourceLineNumber) + { + if (Environment.GetEnvironmentVariable("DURABLE_AGENT_EXPIRY_INTEGRATION") != "1" || + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DURABLE_AGENT_EXPIRY_EMULATOR_ENDPOINT"))) + { + this.Skip = "NOT RUN: requires DURABLE_AGENT_EXPIRY_INTEGRATION=1 and an explicitly isolated " + + "DURABLE_AGENT_EXPIRY_EMULATOR_ENDPOINT; never defaults to shared localhost:8080 or starts containers."; + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ResultExpiryOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ResultExpiryOrchestrationTests.cs new file mode 100644 index 0000000..b26d2c3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ResultExpiryOrchestrationTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// Executes the backend test's actual orchestration handler without connecting to a backend. +public sealed class ResultExpiryOrchestrationTests +{ + private static readonly EntityInstanceId s_id = new("dafx-ExpiryProbe", "local-proof"); + private static readonly JsonElement s_input = JsonSerializer.SerializeToElement(new AgentEntityResultExpirationCheck( + new DateTimeOffset(2026, 9, 12, 0, 0, 0, TimeSpan.Zero), "5b9ddf23b2d94e42b1dd4d946134f043", s_id.ToString())); + + [Fact] + public async Task SuccessfulEntityCallReturnsTrueAsync() + { + Mock entities = CreateEntities(null); + Assert.True(await InvokeAsync(entities)); + VerifySingleCall(entities); + } + + [Fact] + public async Task InjectedCleanupSdkFailureReturnsFalseAsync() + { + EntityOperationFailedException failure = new(s_id, nameof(AgentEntity.CheckAndExpireResults), InjectedDetails()); + Mock entities = CreateEntities(failure); + Assert.False(await InvokeAsync(entities)); + VerifySingleCall(entities); + } + + [Theory] + [InlineData("entity")] + [InlineData("operation")] + [InlineData("command")] + [InlineData("type")] + [InlineData("message")] + [InlineData("inner")] + public async Task UnexpectedEntityFailuresPropagateAsync(string mismatch) + { + TaskFailureDetails expected = InjectedDetails(); + TaskFailureDetails details = new( + mismatch == "type" ? typeof(InvalidOperationException).FullName! : expected.ErrorType, + mismatch == "message" ? "unexpected failure" : expected.ErrorMessage, + expected.StackTrace, + mismatch == "inner" ? TaskFailureDetails.FromException(new InvalidOperationException("unexpected cause")) : null, + expected.Properties); + string operation = mismatch == "command" ? nameof(AgentEntity.Run) : nameof(AgentEntity.CheckAndExpireResults); + EntityOperationFailedException failure = new( + mismatch == "entity" ? new EntityInstanceId(s_id.Name, "other") : s_id, + mismatch == "operation" ? nameof(AgentEntity.Run) : operation, details); + Mock entities = CreateEntities(failure, operation); + Assert.Same(failure, await Assert.ThrowsAsync(() => InvokeAsync(entities, operation))); + VerifySingleCall(entities, operation); + } + + [Theory] + [InlineData("task")] + [InlineData("local")] + [InlineData("cancellation")] + public async Task NonEntityFailuresPropagateAsync(string kind) + { + Exception failure = kind switch + { + "task" => new TaskFailedException("unexpected-task", 0, InjectedDetails()), + "cancellation" => new OperationCanceledException(), + _ => new InvalidOperationException(ResultExpiryAtomicityTests.InjectedCleanupFailureException.FailureMessage), + }; + Mock entities = CreateEntities(failure); + Assert.Same(failure, await Assert.ThrowsAsync(failure.GetType(), () => InvokeAsync(entities))); + VerifySingleCall(entities); + } + + private static TaskFailureDetails InjectedDetails() => + JsonSerializer.Deserialize(JsonSerializer.Serialize(TaskFailureDetails.FromException( + new ResultExpiryAtomicityTests.InjectedCleanupFailureException())))!; + + private static Mock CreateEntities( + Exception? failure, string operation = nameof(AgentEntity.CheckAndExpireResults)) + { + Mock entities = new(MockBehavior.Strict); + entities.Setup(value => value.CallEntityAsync( + s_id, operation, s_input, It.IsAny())) + .Returns(() => failure is null ? Task.CompletedTask : Task.FromException(failure)); + return entities; + } + + private static Task InvokeAsync( + Mock entities, string operation = nameof(AgentEntity.CheckAndExpireResults)) => + ResultExpiryAtomicityTests.InvokeEntityAsync(entities.Object, s_id, operation, s_input); + + private static void VerifySingleCall( + Mock entities, string operation = nameof(AgentEntity.CheckAndExpireResults)) => + entities.Verify(value => value.CallEntityAsync( + s_id, operation, s_input, It.IsAny()), Times.Once); +} 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..c702b0e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityDeliveryTests.cs @@ -0,0 +1,1181 @@ +// 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); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ColdMailboxHistoryProjectsMediaLessUriWithoutLosingCanonicalStateAsync(bool requestHistory) + { + const string MessageJson = """ + {"role":"developer","messageId":"media-message","authorName":"producer","createdAt":"2026-09-12T00:00:00Z", + "extensionData":{"flag":false},"futureMessage":{"value":null},"contents":[ + {"$type":"uri","uri":"https://example.test/media","futureUri":{"value":0}}, + {"$type":"uri","uri":"https://example.test/image","mediaType":"image/png"}, + {"$type":"unknown","content":{"future":false}}]} + """; + DurableAgentStateMessage message = JsonSerializer.Deserialize( + MessageJson, DurableAgentStateJsonContext.Default.DurableAgentStateMessage)!; + DurableAgentState state = CreateRevisedState("old", "retained"); + state.MailboxWritesAuthorized = true; + state.Data.ConversationHistory.Add(requestHistory + ? new DurableAgentStateRequest { CorrelationId = "old", Messages = [message] } + : new DurableAgentStateResponse { CorrelationId = "old", Messages = [message] }); + RecordingAgent agent = new("agent"); + EntityHarness harness = CreateHarness(agent, Reload(state)); + + await harness.RunAsync(new RunRequest("next") { CorrelationId = "next" }); + + ChatMessage modelMessage = agent.LastMessages[0]; + Assert.Equal(new ChatRole("developer"), modelMessage.Role); + Assert.Equal(message.MessageId, modelMessage.MessageId); + Assert.Equal(message.AuthorName, modelMessage.AuthorName); + Assert.Equal(message.CreatedAt, modelMessage.CreatedAt); + Assert.False(Assert.IsType(modelMessage.AdditionalProperties!["flag"]).GetBoolean()); + JsonElement opaque = Assert.IsType(Assert.IsType(modelMessage.Contents[0]).RawRepresentation); + Assert.Equal("uri", opaque.GetProperty("$type").GetString()); + Assert.False(opaque.TryGetProperty("mediaType", out _)); + Assert.Equal(0, opaque.GetProperty("futureUri").GetProperty("value").GetInt32()); + Assert.Equal("image/png", Assert.IsType(modelMessage.Contents[1]).MediaType); + Assert.False(Assert.IsType(modelMessage.Contents[2].RawRepresentation).GetProperty("future").GetBoolean()); + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + Assert.True(JsonElement.DeepEquals( + JsonSerializer.SerializeToElement(message, DurableAgentStateJsonContext.Default.DurableAgentStateMessage), + JsonSerializer.SerializeToElement(committed.Data.ConversationHistory[0].Messages[0], + DurableAgentStateJsonContext.Default.DurableAgentStateMessage))); + Assert.Throws(() => message.ToChatMessage()); + } + + [Fact] + public async Task ResponseWithDefaultMediaUriSurvivesNextColdInvocationAsync() + { + RecordingAgent firstAgent = new("agent") + { + ResponseUpdate = new AgentResponseUpdate(ChatRole.Assistant, + [new UriContent(new Uri("https://example.test/media"), null!)]), + }; + EntityHarness first = CreateHarness(firstAgent, state: null); + await first.RunAsync(new RunRequest("first") { CorrelationId = "first" }); + DurableAgentState state = Reload(Assert.IsType(first.PersistedState)); + Assert.Equal("application/octet-stream", Assert.IsType( + state.Data.ConversationHistory[1].Messages[0].Contents[0]).MediaType); + RecordingAgent nextAgent = new("agent"); + EntityHarness next = CreateHarness(nextAgent, state); + + await next.RunAsync(new RunRequest("next") { CorrelationId = "next" }); + + UriContent uri = Assert.IsType(nextAgent.LastMessages[1].Contents[0]); + Assert.Equal("application/octet-stream", uri.MediaType); + Assert.Equal("https://example.test/media", uri.Uri.ToString()); + } + + [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.True(JsonElement.DeepEquals(state.Data.Session!.Value, committed.Data.Session!.Value)); + 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.True(JsonElement.DeepEquals(state.Data.Session!.Value, committed.Data.Session!.Value)); + Assert.Equal(state.Data.IngestedPositions, committed.Data.IngestedPositions); + Assert.Equal(state.Data.Truncation!.EvictedMessageCount, committed.Data.Truncation!.EvictedMessageCount); + Assert.True(JsonElement.DeepEquals(state.Data.HistoryBinding, committed.Data.HistoryBinding)); + Assert.Equal(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); + Assert.Equal(state.Data.HistoryBinding.ValueKind, committed.Data.HistoryBinding.ValueKind); + if (bindingJson is not null) + { + Assert.True(JsonElement.DeepEquals(state.Data.HistoryBinding, committed.Data.HistoryBinding)); + } + + Assert.Single(state.Data.CompletionReceipts!); + Assert.Equal(2, committed.Data.CompletionReceipts!.Count); + } + + 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, + Action? onSignal = null, + Action? onSignalInput = null, + 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); + context.Setup(value => value.SignalEntity( + sessionId, It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (_, operationName, input, signalOptions) => + { + onSignal?.Invoke(operationName, signalOptions); + onSignalInput?.Invoke(input); + }); + 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.SetupGet(value => value.Name).Returns(nameof(AgentEntity.Run)); + operation.SetupGet(value => value.HasInput).Returns(true); + operation.Setup(value => value.GetInput(typeof(RunRequest))).Returns(request); + object? result = await ((ITaskEntity)entity).RunAsync(operation.Object); + return Assert.IsType(result); + } + + public async Task CheckResultsExpirationAsync(AgentEntityResultExpirationCheck? scheduledCheck = null, bool? hasInput = null) + { + operation.SetupGet(value => value.Name).Returns("CheckAndExpireResults"); + operation.SetupGet(value => value.HasInput).Returns(hasInput ?? scheduledCheck is not null); + operation.Setup(value => value.GetInput(typeof(AgentEntityResultExpirationCheck))).Returns(scheduledCheck); + _ = await ((ITaskEntity)entity).RunAsync(operation.Object); + } + } + + 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/AgentEntityExpiryChainTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityExpiryChainTests.cs new file mode 100644 index 0000000..bae599d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityExpiryChainTests.cs @@ -0,0 +1,628 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; +using static Microsoft.Agents.AI.DurableTask.Tests.Unit.AgentEntityDeliveryTests; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class AgentEntityExpiryChainTests +{ + private static readonly DateTimeOffset s_now = new(2026, 9, 12, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task HundredNewRunsHaveExactlyOnePendingSignalAsync() + { + DurableAgentState? state = null; + List signals = []; + RecordingAgent agent = new("agent"); + for (int run = 0; run < 100; run++) + { + EntityHarness harness = CreateHarness(agent, state, + resultRetentionPeriod: TimeSpan.FromMinutes(20), timeProvider: new Clock(s_now), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await harness.RunAsync(new RunRequest("request") { CorrelationId = $"request-{run}" }); + state = Reload(Assert.IsType(harness.PersistedState)); + Assert.Single(signals); + } + + Assert.Equal(100, agent.InvocationCount); + Assert.Equal(100, state!.Data.TerminalResults!.Count); + Assert.Single(signals); + } + + [Fact] + public async Task HundredDuplicateDeliveriesScheduleExactlyOneSuccessorAsync() + { + List signals = []; + EntityHarness first = CreateHarness(new RecordingAgent("agent"), state: null, + resultRetentionPeriod: TimeSpan.FromMinutes(2), timeProvider: new Clock(s_now), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await first.RunAsync(new RunRequest("request") { CorrelationId = "request" }); + AgentEntityResultExpirationCheck original = Assert.Single(signals); + DurableAgentState state = Reload(Assert.IsType(first.PersistedState)); + DurableAgentStateOutcomeResolver.AddSuccessfulResult(state, "future", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "future")), s_now, s_now.AddMinutes(20)); + for (int delivery = 0; delivery < 100; delivery++) + { + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now.AddMinutes(2)), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await cleanup.CheckResultsExpirationAsync(original); + if (cleanup.PersistedState is DurableAgentState committed) + { + state = Reload(committed); + } + + Assert.Equal(2, signals.Count); + Assert.Equal(delivery == 0, cleanup.StateWasPersisted); + } + + Assert.Equal("future", Assert.Single(state.Data.TerminalResults!).Key); + Assert.Equal(2, signals.Count); + } + + [Fact] + public async Task EarlierDeadlineSupersedesOnceAndLaterRunsReusePendingCheckAsync() + { + List signals = []; + DurableAgentState state = await RunAsync(null, "first", 20, signals); + AgentEntityResultExpirationCheck first = Assert.Single(signals); + state = await RunAsync(state, "earlier", 5, signals); + Assert.Equal(2, signals.Count); + AgentEntityResultExpirationCheck earlier = signals[1]; + Assert.Equal(s_now.AddMinutes(5), earlier.ScheduledTime); + Assert.NotEqual(first.Token, earlier.Token); + state = await RunAsync(state, "later", 40, signals); + Assert.Equal(2, signals.Count); + Assert.Equal(earlier, Pending(state)); + await AssertStaleAsync(state, first, signals, s_now.AddHours(1)); + Assert.Equal(2, signals.Count); + + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(earlier.ScheduledTime), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await cleanup.CheckResultsExpirationAsync(earlier); + state = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Equal(3, signals.Count); + Assert.Equal(s_now.AddMinutes(20), signals[2].ScheduledTime); + Assert.Equal(2, state.Data.TerminalResults!.Count); + await AssertStaleAsync(state, earlier, signals, s_now.AddHours(1)); + Assert.Equal(3, signals.Count); + } + + [Fact] + public async Task LaterReplacementKeepsEarlierCheckUntilItConsumesAndRotatesAsync() + { + List signals = []; + DurableAgentState state = await RunAsync(null, "first", 5, signals); + AgentEntityResultExpirationCheck first = Assert.Single(signals); + Assert.True(DurableAgentStateOutcomeResolver.MarkExpiredResultUnavailable(state, "first", s_now.AddMinutes(5))); + // A compatible import has removed the first payload; the worker clock may be behind it. + state = await RunAsync(state, "replacement", 20, signals); + Assert.Single(signals); + Assert.Equal(first, Pending(state)); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(first.ScheduledTime), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await cleanup.CheckResultsExpirationAsync(first); + state = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Equal(2, signals.Count); + Assert.Equal(s_now.AddMinutes(20), signals[1].ScheduledTime); + Assert.NotEqual(first.Token, signals[1].Token); + await AssertStaleAsync(state, first, signals, s_now.AddHours(1)); + Assert.Equal(2, signals.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OverdueFailedDeliveryRecoveryReplacesStuckTokenExactlyOnceAsync(bool newRun) + { + List signals = []; + DurableAgentState state = await RunAsync(null, "first", 2, signals); + state = await RunAsync(state, "future", 20, signals); + AgentEntityResultExpirationCheck first = Assert.Single(signals); + string before = Serialize(state); + int attempts = 0; + EntityHarness failed = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now.AddMinutes(3)), + onSignal: (_, _) => + { + attempts++; + throw new InvalidOperationException("outbox staging failed"); + }); + await Assert.ThrowsAsync(() => failed.CheckResultsExpirationAsync(first)); + Assert.Equal(1, attempts); + Assert.False(failed.StateWasPersisted); + Assert.Equal(before, Serialize(state)); + + EntityHarness recovery = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now.AddMinutes(3)), + onSignalInput: input => signals.Add(Assert.IsType(input))); + if (newRun) + { + await recovery.RunAsync(new RunRequest("new") { CorrelationId = "new" }); + } + else + { + await recovery.CheckResultsExpirationAsync(); + } + + state = Reload(Assert.IsType(recovery.PersistedState)); + Assert.Equal(2, signals.Count); + Assert.NotEqual(first.Token, signals[1].Token); + Assert.Equal("unavailable", state.Data.CompletionReceipts!["first"].ResultState); + for (int recoveryCount = 0; recoveryCount < 100; recoveryCount++) + { + EntityHarness repeat = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now.AddMinutes(3)), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await repeat.CheckResultsExpirationAsync(); + state = Reload(Assert.IsType(repeat.PersistedState)); + Assert.Equal(2, signals.Count); + } + + await AssertStaleAsync(state, first, signals, s_now.AddHours(1)); + Assert.Equal(2, signals.Count); + } + + [Fact] + public async Task ClearedDeletedNonExpiringAndForeignGenerationsNeverReviveOldChainAsync() + { + List signals = []; + DurableAgentState state = await RunAsync(null, "first", 2, signals); + AgentEntityResultExpirationCheck first = Assert.Single(signals); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(first.ScheduledTime), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await cleanup.CheckResultsExpirationAsync(first); + state = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Null(Pending(state)); + Assert.Empty(state.Data.TerminalResults!); + await AssertStaleAsync(state, first, signals, s_now.AddHours(1)); + await AssertStaleAsync(null, first, signals, s_now.AddHours(1)); + state = await RunAsync(null, "first", null, signals); + await AssertStaleAsync(state, first, signals, s_now.AddHours(1)); + Assert.Single(signals); + state = await RunAsync(null, "first", 2, signals); + Assert.Equal(2, signals.Count); + Assert.NotEqual(first.Token, signals[1].Token); + await AssertStaleAsync(state, first, signals, s_now.AddHours(1)); + await AssertStaleAsync(state, signals[1] with { EntityId = "foreign" }, signals, s_now); + await AssertStaleAsync(state, signals[1] with { ScheduledTime = s_now.AddMinutes(3) }, signals, s_now); + await AssertStaleAsync(state, new AgentEntityResultExpirationCheck(signals[1].ScheduledTime), signals, s_now); + Assert.Equal(2, signals.Count); + } + + [Theory] + [InlineData("null")] + [InlineData("false")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("""{"version":2,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":null,"token":null}""")] + [InlineData("""{"version":1,"entityId":"foreign","scheduledResultExpiryUtc":null,"token":null}""")] + [InlineData("""{"version":1,"version":1,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":null,"token":null}""")] + [InlineData("""{"version":1,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":"bad","token":"abc"}""")] + [InlineData("""{"version":1,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":null,"token":"abc"}""")] + [InlineData("""{"version":1,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":"2026-09-12T00:00:00Z","token":"00000000000000000000000000000000"}""")] + [InlineData("""{"version":"1","entityId":"@dafx-agent@session","scheduledResultExpiryUtc":null,"token":null}""")] + [InlineData("""{"version":1,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":0,"token":null}""")] + [InlineData("""{"version":1,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":null}""")] + [InlineData("""{"version":1,"entityId":"@dafx-agent@session","scheduledResultExpiryUtc":"2026-09-12T02:00:00+02:00","token":"5b9ddf23b2d94e42b1dd4d946134f043"}""")] + public async Task InvalidFutureProfileBlocksWritersBeforeModelButRemainsOpaqueForDeliveryAsync(string profile) + { + List signals = []; + DurableAgentState original = await RunAsync(null, "first", 20, signals); + using JsonDocument document = JsonDocument.Parse(profile); + DurableAgentState state = WithProfile(original, document.RootElement.Clone()); + string before = Serialize(state); + RecordingAgent agent = new("agent"); + EntityHarness writer = CreateHarness(agent, state, timeProvider: new Clock(s_now), + onSignalInput: input => signals.Add(Assert.IsType(input))); + InvalidOperationException error = await Assert.ThrowsAsync( + () => writer.RunAsync(new RunRequest("new") { CorrelationId = "new" })); + Assert.Contains(AgentEntityResultExpirySchedule.ExtensionName, error.Message); + Assert.Equal(0, agent.InvocationCount); + Assert.False(writer.StateWasPersisted); + await Assert.ThrowsAsync(() => writer.CheckResultsExpirationAsync()); + Assert.Equal(before, Serialize(state)); + Assert.Single(signals); + + AgentResponse duplicate = await writer.RunAsync(new RunRequest([]) { CorrelationId = "first" }); + Assert.Equal("response", duplicate.Text); + Assert.Equal(0, agent.InvocationCount); + Assert.Equal(before, Serialize(state)); + AgentRunHandle handle = AgentRunHandleTests.CreateHandle(state, correlationId: "first", timeProvider: new Clock(s_now)); + Assert.Equal("response", (await handle.ReadAgentResponseAsync()).Text); + Assert.Equal(before, Serialize(state)); + } + + [Fact] + public async Task ProfileUnknownFieldsAndOtherExtensionsSurviveRotationAndClearAsync() + { + List signals = []; + DurableAgentState state = await RunAsync(null, "first", 20, signals); + Dictionary profile = JsonSerializer.Deserialize>( + state.ExtensionData![AgentEntityResultExpirySchedule.ExtensionName])!; + profile["future"] = JsonSerializer.SerializeToElement(new { flag = false, data = (string?)null }); + state = WithProfile(state, JsonSerializer.SerializeToElement(profile)); + state.ExtensionData!["application"] = JsonSerializer.SerializeToElement(new List { 0, 1 }); + state.UnknownProperties = new Dictionary { ["rootFuture"] = JsonSerializer.SerializeToElement(false) }; + string before = Serialize(state); + DurableAgentState rotated = await RunAsync(state, "earlier", 5, signals); + Assert.Equal(before, Serialize(state)); + Assert.Equal(2, signals.Count); + AssertPreserved(rotated); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), rotated, timeProvider: new Clock(s_now.AddHours(1))); + await cleanup.CheckResultsExpirationAsync(signals[1]); + DurableAgentState cleared = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Null(Pending(cleared)); + AssertPreserved(cleared); + + void AssertPreserved(DurableAgentState value) + { + Assert.True(JsonElement.DeepEquals(profile["future"], value.ExtensionData![AgentEntityResultExpirySchedule.ExtensionName].GetProperty("future"))); + Assert.True(JsonElement.DeepEquals(state.ExtensionData!["application"], value.ExtensionData["application"])); + Assert.False(value.UnknownProperties!["rootFuture"].GetBoolean()); + } + } + + [Theory] + [InlineData("model")] + [InlineData("serialization")] + [InlineData("cancel-before")] + [InlineData("cancel-after-signal")] + [InlineData("signal")] + [InlineData("state")] + public async Task FailedRunLeavesPendingTokenAndRetrySchedulesExactlyOnceAsync(string failure) + { + List signals = []; + DurableAgentState state = await RunAsync(null, "first", 20, signals); + string before = Serialize(state); + using CancellationTokenSource cancellation = new(); + if (failure == "cancel-before") + { + cancellation.Cancel(); + } + + int attempts = 0; + RecordingAgent agent = new("agent") + { + Exception = failure == "model" ? new InvalidOperationException("model failed") : null, + UnsupportedResponseMetadata = failure == "serialization" ? new object() : null, + }; + EntityHarness failed = CreateHarness(agent, state, timeProvider: new Clock(s_now), + resultRetentionPeriod: TimeSpan.FromMinutes(2), cancellationToken: cancellation.Token, + onCommit: _ => + { + if (failure == "state") + { + throw new InvalidOperationException("state failed"); + } + }, + onSignal: (_, _) => + { + attempts++; + if (failure == "signal") + { + throw new InvalidOperationException("signal failed"); + } + + if (failure == "cancel-after-signal") + { + cancellation.Cancel(); + } + }); + await Assert.ThrowsAnyAsync(() => failed.RunAsync(new RunRequest("retry") { CorrelationId = "retry" })); + Assert.Equal(failure is "signal" or "state" or "cancel-after-signal" ? 1 : 0, attempts); + Assert.False(failed.StateWasPersisted); + Assert.Equal(before, Serialize(state)); + DurableAgentState retried = await RunAsync(state, "retry", 2, signals); + Assert.Equal(2, signals.Count); + Assert.Equal(2, retried.Data.TerminalResults!.Count); + Assert.Equal(signals[1], Pending(retried)); + retried = await RunAsync(retried, "retry", 2, signals); + Assert.Equal(2, signals.Count); + Assert.Equal(2, retried.Data.CompletionReceipts!.Count); + } + + [Theory] + [InlineData("cancel-before")] + [InlineData("cancel-after-signal")] + [InlineData("signal")] + [InlineData("state")] + [InlineData("serialization")] + [InlineData("invalid-mailbox")] + public async Task FailedCleanupRetainsTokenAndRetryConsumesItExactlyOnceAsync(string failure) + { + List signals = []; + DurableAgentState state = await RunAsync(null, "first", 2, signals); + state = await RunAsync(state, "future", 20, signals); + AgentEntityResultExpirationCheck first = Assert.Single(signals); + string before = Serialize(state); + DurableAgentState attempted = Reload(state); + if (failure == "serialization") + { + attempted.UnknownProperties = new Dictionary { ["invalid"] = default }; + } + else if (failure == "invalid-mailbox") + { + attempted.Data.CompletionReceipts!.Remove("first"); + } + + using CancellationTokenSource cancellation = new(); + if (failure == "cancel-before") + { + cancellation.Cancel(); + } + + int attempts = 0; + EntityHarness failed = CreateHarness(new RecordingAgent("agent"), attempted, + timeProvider: new Clock(s_now.AddMinutes(3)), cancellationToken: cancellation.Token, + onCommit: _ => + { + if (failure == "state") + { + throw new InvalidOperationException("state failed"); + } + }, + onSignal: (_, _) => + { + attempts++; + if (failure == "signal") + { + throw new InvalidOperationException("signal failed"); + } + + if (failure == "cancel-after-signal") + { + cancellation.Cancel(); + } + }); + await Assert.ThrowsAnyAsync(() => failed.CheckResultsExpirationAsync(first)); + Assert.Equal(failure is "signal" or "state" or "cancel-after-signal" ? 1 : 0, attempts); + Assert.False(failed.StateWasPersisted); + Assert.Equal(first, Pending(attempted)); + Assert.Equal(2, attempted.Data.TerminalResults!.Count); + Assert.Equal(before, Serialize(state)); + if (failure is not ("serialization" or "invalid-mailbox")) + { + Assert.Equal(before, Serialize(attempted)); + } + + EntityHarness retry = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now.AddMinutes(3)), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await retry.CheckResultsExpirationAsync(first); + DurableAgentState retried = Reload(Assert.IsType(retry.PersistedState)); + Assert.Equal(2, signals.Count); + Assert.Equal(signals[1], Pending(retried)); + Assert.Equal("future", Assert.Single(retried.Data.TerminalResults!).Key); + await AssertStaleAsync(retried, first, signals, s_now.AddMinutes(3)); + Assert.Equal(2, signals.Count); + } + + [Fact] + public async Task PublicRetentionSettingCannotEnableProductionMailboxWritesAsync() + { + int signals = 0; + EntityHarness production = CreateHarness(new RecordingAgent("agent"), state: null, enableMailboxWrites: false, + authorizeLegacyMigration: false, resultRetentionPeriod: TimeSpan.FromMinutes(2), timeProvider: new Clock(s_now), + onSignal: (_, _) => signals++); + await production.RunAsync(new RunRequest("request") { CorrelationId = "request" }); + DurableAgentState state = Reload(Assert.IsType(production.PersistedState)); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, state.SchemaVersion); + Assert.Null(state.Data.TerminalResults); + Assert.Null(state.Data.CompletionReceipts); + Assert.Null(state.ExtensionData); + Assert.Equal(0, signals); + Assert.Null(typeof(DurableAgentsOptions).GetProperty("EnableMailboxWrites")); + Assert.Null(typeof(DurableAgentsOptions).GetProperty("EnableMailboxEntityDeletion")); + } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + public async Task LegacyWriterDoesNotInterpretOrCreateRuntimeProfileAsync(string version) + { + DurableAgentState state = new() + { + SchemaVersion = version, + ExtensionData = new Dictionary + { + [AgentEntityResultExpirySchedule.ExtensionName] = JsonSerializer.SerializeToElement(false), + }, + }; + int signals = 0; + EntityHarness production = CreateHarness(new RecordingAgent("agent"), state, enableMailboxWrites: false, + authorizeLegacyMigration: false, resultRetentionPeriod: TimeSpan.FromMinutes(2), timeProvider: new Clock(s_now), + onSignal: (_, _) => signals++); + await production.RunAsync(new RunRequest("request") { CorrelationId = "request" }); + DurableAgentState committed = Reload(Assert.IsType(production.PersistedState)); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, committed.SchemaVersion); + Assert.False(committed.ExtensionData![AgentEntityResultExpirySchedule.ExtensionName].GetBoolean()); + Assert.Null(committed.Data.TerminalResults); + Assert.Equal(0, signals); + Assert.Equal(version, state.SchemaVersion); + } + + [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 LegacyStaleChecksHaveZeroSettersAndSignalsBeforeAndAfterReloadAsync(string version, bool tokenBearing) + { + List signals = []; + _ = await RunAsync(null, "old-generation", 2, signals); + AgentEntityResultExpirationCheck check = tokenBearing + ? Assert.Single(signals) : new AgentEntityResultExpirationCheck(signals[0].ScheduledTime); + DurableAgentState state = new() + { + SchemaVersion = version, + ExtensionData = new Dictionary + { + // Legacy state must preserve, but never interpret, even an unknown profile shape. + [AgentEntityResultExpirySchedule.ExtensionName] = JsonSerializer.SerializeToElement(false), + }, + }; + await AssertInputBearingCheckIsInertAsync(state, check); + await AssertInputBearingCheckIsInertAsync(Reload(state), check); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DeletedAndRecreatedDefaultOffLegacyEntityRejectsOldGenerationAsync(bool tokenBearing) + { + List signals = []; + _ = await RunAsync(null, "old-generation", 2, signals); + AgentEntityResultExpirationCheck check = tokenBearing + ? Assert.Single(signals) : new AgentEntityResultExpirationCheck(signals[0].ScheduledTime); + await AssertInputBearingCheckIsInertAsync(null, check); + + // Recreate the same entity identity from deleted state using production's default-off gates. + EntityHarness recreated = CreateHarness(new RecordingAgent("agent"), state: null, + enableMailboxWrites: false, authorizeLegacyMigration: false); + await recreated.RunAsync(new RunRequest("new") { CorrelationId = "new-generation" }); + DurableAgentState state = Reload(Assert.IsType(recreated.PersistedState)); + Assert.Equal(DurableAgentState.CurrentSchemaVersion, state.SchemaVersion); + Assert.Null(state.ExtensionData); + await AssertInputBearingCheckIsInertAsync(state, check); + } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + [InlineData("2.0.0")] + [InlineData(null)] + public async Task InputBearingNullIsNotExplicitRecoveryAsync(string? version) + { + List signals = []; + DurableAgentState? state = version switch + { + null => null, + "2.0.0" => await RunAsync(null, "expired", 2, signals), + _ => new DurableAgentState { SchemaVersion = version }, + }; + await AssertInputBearingCheckIsInertAsync(state is null ? null : Reload(state), null); + } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + [InlineData("0.0.0")] + [InlineData("1.3.0")] + [InlineData("3.0.0")] + [InlineData("invalid")] + public async Task InputBearingChecksDoNotHideMixedOrUnsupportedStateAsync(string version) + { + DurableAgentState state = new() + { + SchemaVersion = version, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary(), + }, + }; + int writes = 0; + int signals = 0; + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, + enableMailboxWrites: false, authorizeLegacyMigration: false, + onCommit: _ => writes++, onSignal: (_, _) => signals++); + await Assert.ThrowsAsync( + () => harness.CheckResultsExpirationAsync(new AgentEntityResultExpirationCheck(s_now))); + Assert.Equal(0, writes); + Assert.Equal(0, signals); + } + + private static async Task AssertInputBearingCheckIsInertAsync( + DurableAgentState? state, AgentEntityResultExpirationCheck? check) + { + string? before = state is null ? null : Serialize(state); + int writes = 0; + int signals = 0; + RecordingAgent agent = new("agent"); + EntityHarness harness = CreateHarness(agent, state, + enableMailboxWrites: false, authorizeLegacyMigration: false, timeProvider: new Clock(s_now.AddHours(1)), + registerWithFactory: true, onFactoryInvoked: () => Assert.Fail("stale check invoked factory"), + onCommit: _ => writes++, onSignal: (_, _) => signals++); + await harness.CheckResultsExpirationAsync(check, hasInput: true); + Assert.Equal(0, writes); + Assert.Equal(0, signals); + Assert.False(harness.StateWasPersisted); + Assert.Equal(0, agent.InvocationCount); + Assert.Equal(before, state is null ? null : Serialize(state)); + } + + [Theory] + [InlineData(-120)] + [InlineData(0)] + [InlineData(120)] + public async Task ImportedOffsetDeadlinesPersistUtcScheduleAndSurviveReloadAsync(int offsetMinutes) + { + List signals = []; + DurableAgentState state = await RunAsync(null, "forever", null, signals); + DurableAgentStateOutcomeResolver.AddSuccessfulResult(state, "imported", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "imported")), s_now, + s_now.AddMinutes(20).ToOffset(TimeSpan.FromMinutes(offsetMinutes))); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await cleanup.CheckResultsExpirationAsync(); + state = Reload(Assert.IsType(cleanup.PersistedState)); + AgentEntityResultExpirationCheck pending = Assert.Single(signals); + Assert.Equal(TimeSpan.Zero, pending.ScheduledTime.Offset); + Assert.Equal(s_now.AddMinutes(20), pending.ScheduledTime); + Assert.Equal(pending, Pending(state)); + state = await RunAsync(state, "next", 40, signals); + Assert.Single(signals); + Assert.Equal(pending, Pending(state)); + } + + private static async Task RunAsync( + DurableAgentState? state, string correlation, int? retentionMinutes, List signals) + { + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now), + resultRetentionPeriod: retentionMinutes.HasValue ? TimeSpan.FromMinutes(retentionMinutes.Value) : null, + onSignalInput: input => signals.Add(Assert.IsType(input))); + await harness.RunAsync(new RunRequest("request") { CorrelationId = correlation }); + return Reload(Assert.IsType(harness.PersistedState)); + } + + private static async Task AssertStaleAsync( + DurableAgentState? state, AgentEntityResultExpirationCheck signal, List signals, DateTimeOffset now) + { + int beforeCount = signals.Count; + string? before = state is null ? null : Serialize(state); + EntityHarness stale = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(now), + registerWithFactory: true, onFactoryInvoked: () => Assert.Fail("stale signal invoked factory"), + onCommit: _ => Assert.Fail("stale signal wrote state"), + onSignalInput: input => signals.Add(Assert.IsType(input))); + await stale.CheckResultsExpirationAsync(signal); + Assert.Equal(beforeCount, signals.Count); + Assert.Equal(before, state is null ? null : Serialize(state)); + } + + private static AgentEntityResultExpirationCheck? Pending(DurableAgentState state) => + AgentEntityResultExpirySchedule.Read(state, new AgentSessionId("agent", "session").ToString())?.Pending; + + private static DurableAgentState WithProfile(DurableAgentState state, JsonElement profile) => new() + { + SchemaVersion = state.SchemaVersion, + MailboxWritesAuthorized = true, + Data = state.Data, + ExtensionData = new Dictionary { [AgentEntityResultExpirySchedule.ExtensionName] = profile }, + UnknownProperties = state.UnknownProperties, + }; + + private static string Serialize(DurableAgentState state) => + JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + private static DurableAgentState Reload(DurableAgentState state) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState), + DurableAgentStateJsonContext.Default.DurableAgentState)!; + + private sealed class Clock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityResultExpirationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityResultExpirationTests.cs new file mode 100644 index 0000000..74cd90f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityResultExpirationTests.cs @@ -0,0 +1,489 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.AI; +using static Microsoft.Agents.AI.DurableTask.Tests.Unit.AgentEntityDeliveryTests; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class AgentEntityResultExpirationTests +{ + private static readonly DateTimeOffset s_now = new(2026, 9, 12, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task SuccessfulRunSchedulesCleanupAndColdDueTurnPersistsUnavailableAsync() + { + List signals = []; + AgentEntityResultExpirationCheck? scheduledCheck = null; + EntityHarness first = CreateHarness(new RecordingAgent("agent"), state: null, + resultRetentionPeriod: TimeSpan.FromMinutes(2), timeProvider: new Clock(s_now), + onSignalInput: input => scheduledCheck = JsonSerializer.Deserialize( + JsonSerializer.Serialize(Assert.IsType(input))), + onSignal: (name, options) => CaptureSignal(signals, name, options)); + await first.RunAsync(new RunRequest("request") { CorrelationId = "request" }); + Assert.Equal(s_now.AddMinutes(2), Assert.Single(signals)); + DurableAgentState committed = Reload(Assert.IsType(first.PersistedState)); + DurableAgentState before = Reload(committed); + EntityHarness cleanup = CleanupHarness(committed, signals, s_now.AddMinutes(2)); + + await cleanup.CheckResultsExpirationAsync(Assert.IsType(scheduledCheck)); + + DurableAgentState cleaned = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Empty(cleaned.Data.TerminalResults!); + DurableAgentStateCompletionReceipt receipt = Assert.Single(cleaned.Data.CompletionReceipts!).Value; + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, receipt.ResultState); + Assert.Equal(s_now, receipt.CompletedAt); + Assert.Equal(s_now.AddMinutes(2), receipt.ResultExpiresAt); + Assert.Equal(s_now.AddMinutes(2), receipt.ResultUnavailableAt); + Assert.Equal(Serialize(before), Serialize(committed)); + Assert.Null(cleaned.Data.ExpirationTimeUtc); + Assert.Single(signals); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ImportedExpiredOutcomesCleanWithoutModelOrDeletionAndStayUnavailableAsync(bool failed) + { + DurableAgentState state = CreateState(failed); + string original = Serialize(state); + List signals = []; + EntityHarness cleanup = CleanupHarness(state, signals, s_now); + + await cleanup.CheckResultsExpirationAsync(); + + DurableAgentState cleaned = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Empty(cleaned.Data.TerminalResults!); + DurableAgentStateCompletionReceipt receipt = cleaned.Data.CompletionReceipts!["request"]; + Assert.Equal(failed ? "failed" : "succeeded", receipt.Outcome); + Assert.Equal(s_now.AddMinutes(-2), receipt.CompletedAt); + Assert.Equal(s_now.AddMinutes(-1), receipt.ResultExpiresAt); + Assert.Equal(s_now, receipt.ResultUnavailableAt); + Assert.Equal("unavailable", receipt.ResultState); + Assert.False(receipt.UnknownProperties!["future"].GetProperty("flag").GetBoolean()); + Assert.Equal(state.Data.ExpirationTimeUtc, cleaned.Data.ExpirationTimeUtc); + Assert.True(JsonElement.DeepEquals(state.Data.HistoryBinding, cleaned.Data.HistoryBinding)); + Assert.True(JsonElement.DeepEquals(state.Data.Session!.Value, cleaned.Data.Session!.Value)); + Assert.Equal(state.Data.IngestedPositions, cleaned.Data.IngestedPositions); + Assert.Equal(original, Serialize(state)); + Assert.Empty(signals); + + EntityHarness repeated = CleanupHarness(cleaned, signals, s_now.AddHours(1)); + await repeated.CheckResultsExpirationAsync(); + DurableAgentState twice = Reload(Assert.IsType(repeated.PersistedState)); + Assert.Equal(Serialize(cleaned), Serialize(twice)); + EntityHarness duplicate = CleanupHarness(twice, signals, s_now.AddHours(1)); + DurableAgentResultUnavailableException exception = await Assert.ThrowsAsync( + () => duplicate.RunAsync(new RunRequest([]) { CorrelationId = "request" })); + Assert.Equal(receipt.Outcome, exception.Outcome); + Assert.False(duplicate.StateWasPersisted); + string beforePoll = Serialize(twice); + AgentRunHandle handle = AgentRunHandleTests.CreateHandle(twice, correlationId: "request", + timeProvider: new Clock(s_now.AddHours(1))); + Assert.Equal(DurableAgentRunOutcomeKind.CompletedResultUnavailable, (await handle.ReadAgentOutcomeAsync()).Kind); + await Assert.ThrowsAsync(() => handle.ReadAgentResponseAsync()); + Assert.Equal(beforePoll, Serialize(twice)); + } + + [Theory] + [InlineData(-10)] + [InlineData(0)] + [InlineData(10)] + public async Task EarlyStaleAndNewGenerationChecksUseCurrentDeadlineAndClockAsync(int clockMinutes) + { + // An explicit imported-state recovery installs one chain, not one chain per invocation. + DurableAgentState state = CreateState(expiresAt: s_now.AddMinutes(20)); + string before = Serialize(state); + List signals = []; + EntityHarness cleanup = CleanupHarness(state, signals, s_now.AddMinutes(clockMinutes)); + + await cleanup.CheckResultsExpirationAsync(); + + Assert.Equal(before, Serialize(state)); + DurableAgentState committed = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Equal(s_now.AddMinutes(20), Assert.Single(signals)); + EntityHarness duplicate = CleanupHarness(committed, signals, s_now.AddMinutes(clockMinutes)); + await duplicate.CheckResultsExpirationAsync(); + Assert.Equal(Serialize(committed), Serialize(Assert.IsType(duplicate.PersistedState))); + Assert.Single(signals); + Assert.All(signals, signal => Assert.True(signal > s_now.AddMinutes(clockMinutes))); + } + + [Fact] + public async Task EarlyScheduledChecksWithBackwardClockAdvanceSchedulingWithoutExpiringPayloadAsync() + { + DurableAgentState state = CreateState(expiresAt: s_now.AddMinutes(20)); + List signals = []; + AgentEntityResultExpirationCheck? previous = null; + EntityHarness first = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now), + onSignalInput: input => previous = Assert.IsType(input)); + await first.CheckResultsExpirationAsync(); + state = Reload(Assert.IsType(first.PersistedState)); + for (int check = 0; check < 3; check++) + { + AgentEntityResultExpirationCheck delivered = Assert.IsType(previous); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now), + onSignal: (name, options) => CaptureSignal(signals, name, options), + onSignalInput: input => previous = Assert.IsType(input)); + await cleanup.CheckResultsExpirationAsync(delivered); + Assert.Equal(delivered.ScheduledTime.AddMinutes(1), signals[^1]); + Assert.Equal(check + 1, signals.Count); + state = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Equal("available", state.Data.CompletionReceipts!["request"].ResultState); + Assert.Single(state.Data.TerminalResults!); + EntityHarness duplicate = CleanupHarness(state, signals, s_now); + await duplicate.CheckResultsExpirationAsync(delivered); + Assert.False(duplicate.StateWasPersisted); + Assert.Equal(check + 1, signals.Count); + } + } + + [Fact] + public async Task CleanupReschedulesNearDeadlineWithPositiveDelayAndExpiresOnlyDueResultsAsync() + { + DurableAgentState state = CreateState(); + DurableAgentStateOutcomeResolver.AddSuccessfulResult(state, "future", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "future")), s_now, s_now.AddSeconds(1)); + DurableAgentStateOutcomeResolver.AddSuccessfulResult(state, "forever", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "forever")), s_now); + List signals = []; + EntityHarness cleanup = CleanupHarness(state, signals, s_now); + await cleanup.CheckResultsExpirationAsync(); + DurableAgentState cleaned = Reload(Assert.IsType(cleanup.PersistedState)); + Assert.Equal(2, cleaned.Data.TerminalResults!.Count); + Assert.False(cleaned.Data.TerminalResults.ContainsKey("request")); + DateTimeOffset scheduled = Assert.Single(signals); + Assert.True(scheduled >= s_now.AddSeconds(1)); + Assert.True(scheduled <= s_now.AddMinutes(1)); + EntityHarness next = CleanupHarness(cleaned, signals, scheduled); + await next.CheckResultsExpirationAsync(); + DurableAgentState finished = Reload(Assert.IsType(next.PersistedState)); + Assert.Equal("forever", Assert.Single(finished.Data.TerminalResults!).Key); + Assert.Equal(3, finished.Data.CompletionReceipts!.Count); + Assert.Single(signals); + } + + [Fact] + public async Task SuccessfulLaterRunRecoversImportedExpiredResultsWithoutRefreshingTheirTtlAsync() + { + DurableAgentState state = CreateState(); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, timeProvider: new Clock(s_now)); + await harness.RunAsync(new RunRequest("next") { CorrelationId = "next" }); + DurableAgentState committed = Reload(Assert.IsType(harness.PersistedState)); + Assert.Equal("next", Assert.Single(committed.Data.TerminalResults!).Key); + Assert.Equal("unavailable", committed.Data.CompletionReceipts!["request"].ResultState); + Assert.Null(committed.Data.TerminalResults!["next"].ResultExpiresAt); + } + + [Fact] + public async Task MissingEntityCleanupDoesNotRecreateStateOrCallFactoryAsync() + { + List signals = []; + bool deleted = false; + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state: null, + registerWithFactory: true, onFactoryInvoked: () => Assert.Fail("factory must not run"), + onCommit: state => deleted = state is null, + onSignal: (name, options) => CaptureSignal(signals, name, options)); + await cleanup.CheckResultsExpirationAsync(); + Assert.True(deleted); + Assert.Null(cleanup.PersistedState); + Assert.Empty(signals); + } + + [Fact] + public async Task OldScheduledCleanupCannotExpireReusedCorrelationInNewGenerationAsync() + { + List signals = []; + AgentEntityResultExpirationCheck? oldCheck = null; + EntityHarness oldGeneration = CreateHarness(new RecordingAgent("agent"), state: null, + timeProvider: new Clock(s_now), resultRetentionPeriod: TimeSpan.FromMinutes(2), + onSignalInput: input => oldCheck = Assert.IsType(input), + onSignal: (name, options) => CaptureSignal(signals, name, options)); + await oldGeneration.RunAsync(new RunRequest("old") { CorrelationId = "reused" }); + DateTimeOffset oldSignal = Assert.Single(signals); + EntityHarness newGeneration = CreateHarness(new RecordingAgent("agent"), state: null, + timeProvider: new Clock(s_now.AddMinutes(1)), resultRetentionPeriod: TimeSpan.FromMinutes(20)); + await newGeneration.RunAsync(new RunRequest("new") { CorrelationId = "reused" }); + DurableAgentState fresh = Reload(Assert.IsType(newGeneration.PersistedState)); + string before = Serialize(fresh); + + EntityHarness stale = CleanupHarness(fresh, signals, oldSignal); + await stale.CheckResultsExpirationAsync(Assert.IsType(oldCheck)); + + Assert.False(stale.StateWasPersisted); + Assert.Equal(before, Serialize(fresh)); + Assert.Single(signals); + Assert.Equal("available", fresh.Data.CompletionReceipts!["reused"].ResultState); + } + + [Fact] + public async Task NoExpiryCleanupPreservesStateAndSchedulesNothingAsync() + { + EntityHarness first = CreateHarness(new RecordingAgent("agent"), state: null, timeProvider: new Clock(s_now), + onSignal: (_, _) => Assert.Fail("no retention must not schedule")); + await first.RunAsync(new RunRequest("request") { CorrelationId = "request" }); + DurableAgentState state = Reload(Assert.IsType(first.PersistedState)); + string before = Serialize(state); + List signals = []; + EntityHarness cleanup = CleanupHarness(state, signals, s_now.AddYears(1)); + + await cleanup.CheckResultsExpirationAsync(); + + Assert.Equal(before, Serialize(Assert.IsType(cleanup.PersistedState))); + Assert.Empty(signals); + } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + [InlineData("3.0.0")] + public async Task CleanupRejectsInvalidLegacyOrUnknownVersionWithoutPromotionAsync(string schemaVersion) + { + DurableAgentState state = new() { SchemaVersion = schemaVersion }; + state.Data.ConversationHistory.Add(new DurableAgentStateRequest + { + Messages = + [ + new DurableAgentStateMessage + { + Role = "user", + Contents = [new DurableAgentStateUriContent { Uri = new Uri("https://example.test/media") }], + }, + ], + }); + EntityHarness harness = CleanupHarness(state, [], s_now); + + await Assert.ThrowsAnyAsync(() => harness.CheckResultsExpirationAsync()); + + Assert.False(harness.StateWasPersisted); + Assert.Equal(schemaVersion, state.SchemaVersion); + Assert.Null(state.Data.CompletionReceipts); + } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + public async Task CleanupNeverPromotesLegacyStateAsync(string schemaVersion) + { + DurableAgentState state = new() { SchemaVersion = schemaVersion }; + state.Data.ConversationHistory.Add(new DurableAgentStateRequest { CorrelationId = "legacy" }); + List signals = []; + EntityHarness cleanup = CleanupHarness(state, signals, s_now); + await cleanup.CheckResultsExpirationAsync(); + DurableAgentState persisted = Assert.IsType(cleanup.PersistedState); + Assert.Equal(schemaVersion, persisted.SchemaVersion); + Assert.Null(persisted.Data.CompletionReceipts); + Assert.Empty(signals); + } + + [Fact] + public async Task WriterDisabledCleanupFailsWithoutMutationOrSchedulingAsync() + { + DurableAgentState state = CreateState(); + string before = Serialize(state); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, enableMailboxWrites: false, + onSignal: (_, _) => Assert.Fail("must not schedule")); + await Assert.ThrowsAsync(() => cleanup.CheckResultsExpirationAsync()); + Assert.Equal(before, Serialize(state)); + Assert.False(cleanup.StateWasPersisted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CleanupSchedulingFailureOrCancellationRollsBackExpiredResultsAsync(bool cancel) + { + DurableAgentState state = CreateState(); + DurableAgentStateOutcomeResolver.AddSuccessfulResult(state, "future", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "future")), s_now, s_now.AddHours(1)); + string before = Serialize(state); + using CancellationTokenSource cancellation = new(); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, + timeProvider: new Clock(s_now), cancellationToken: cancellation.Token, + onSignal: (_, _) => + { + if (cancel) + { + cancellation.Cancel(); + } + else + { + throw new InvalidOperationException("scheduling failed"); + } + }); + if (cancel) + { + await Assert.ThrowsAnyAsync(() => cleanup.CheckResultsExpirationAsync()); + } + else + { + await Assert.ThrowsAsync(() => cleanup.CheckResultsExpirationAsync()); + } + + Assert.False(cleanup.StateWasPersisted); + Assert.Equal(before, Serialize(state)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunSchedulingFailureOrCancellationLeavesNoCompletionAsync(bool cancel) + { + DurableAgentState state = CreateState(expiresAt: s_now.AddMinutes(20)); + string before = Serialize(state); + using CancellationTokenSource cancellation = new(); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, + resultRetentionPeriod: TimeSpan.FromMinutes(2), timeProvider: new Clock(s_now), + cancellationToken: cancellation.Token, onSignal: (_, _) => + { + if (cancel) + { + cancellation.Cancel(); + } + else + { + throw new InvalidOperationException("scheduling failed"); + } + }); + if (cancel) + { + await Assert.ThrowsAnyAsync( + () => harness.RunAsync(new RunRequest("next") { CorrelationId = "next" })); + } + else + { + await Assert.ThrowsAsync( + () => harness.RunAsync(new RunRequest("next") { CorrelationId = "next" })); + } + + Assert.False(harness.StateWasPersisted); + Assert.Equal(before, Serialize(state)); + } + + [Fact] + public async Task PreCancelledCleanupLeavesHydratedStateUnchangedAsync() + { + DurableAgentState state = CreateState(); + string before = Serialize(state); + EntityHarness harness = CreateHarness(new RecordingAgent("agent"), state, + cancellationToken: new CancellationToken(true), onSignal: (_, _) => Assert.Fail("must not schedule")); + await Assert.ThrowsAnyAsync(() => harness.CheckResultsExpirationAsync()); + Assert.False(harness.StateWasPersisted); + Assert.Equal(before, Serialize(state)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InvalidStateOrSerializationFailureCannotPartiallyCleanAsync(bool serializationFailure) + { + DurableAgentState state = CreateState(); + if (serializationFailure) + { + state.UnknownProperties = new Dictionary { ["invalid"] = default }; + } + else + { + state.Data.CompletionReceipts!.Remove("request"); + } + + DurableAgentStateTerminalResult result = state.Data.TerminalResults!["request"]; + EntityHarness cleanup = CleanupHarness(state, [], s_now); + await Assert.ThrowsAnyAsync(() => cleanup.CheckResultsExpirationAsync()); + Assert.Same(result, state.Data.TerminalResults["request"]); + Assert.False(cleanup.StateWasPersisted); + } + + [Fact] + public async Task CleanupCommitFailureLeavesHydratedStateIntactForRetryAsync() + { + DurableAgentState state = CreateState(); + string before = Serialize(state); + EntityHarness cleanup = CreateHarness(new RecordingAgent("agent"), state, + timeProvider: new Clock(s_now), onCommit: _ => throw new InvalidOperationException("commit failed")); + await Assert.ThrowsAsync(() => cleanup.CheckResultsExpirationAsync()); + Assert.Equal(before, Serialize(state)); + EntityHarness retry = CleanupHarness(state, [], s_now); + await retry.CheckResultsExpirationAsync(); + Assert.Empty(Assert.IsType(retry.PersistedState).Data.TerminalResults!); + } + + private static EntityHarness CleanupHarness(DurableAgentState state, List signals, DateTimeOffset now) => + CreateHarness(new RecordingAgent("agent"), state, registerWithFactory: true, + onFactoryInvoked: () => Assert.Fail("cleanup/duplicate must not invoke factory"), + timeProvider: new Clock(now), onSignal: (name, options) => CaptureSignal(signals, name, options)); + + private static void CaptureSignal(List signals, string name, SignalEntityOptions? options) + { + Assert.Equal("CheckAndExpireResults", name); + signals.Add(Assert.IsType(options?.SignalTime)); + } + + private static DurableAgentState CreateState(bool failed = false, DateTimeOffset? expiresAt = null) + { + DateTimeOffset completedAt = s_now.AddMinutes(-2); + DateTimeOffset expiration = expiresAt ?? s_now.AddMinutes(-1); + string outcome = failed ? "failed" : "succeeded"; + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + MailboxWritesAuthorized = true, + Data = new DurableAgentStateData + { + TerminalResults = new Dictionary + { + ["request"] = new() + { + CorrelationId = "request", + Outcome = outcome, + CompletedAt = completedAt, + ResultExpiresAt = expiration, + Response = DurableAgentStateTerminalResponse.FromResponse( + new AgentResponse(new ChatMessage(ChatRole.Assistant, "retained payload")), "request", completedAt), + Error = failed ? new DurableAgentStateTerminalError { Code = "failed", Message = "failure" } : null, + }, + }, + CompletionReceipts = new Dictionary + { + ["request"] = new() + { + CorrelationId = "request", + Outcome = outcome, + CompletedAt = completedAt, + ResultExpiresAt = expiration, + ResultState = "available", + UnknownProperties = new Dictionary + { + ["future"] = JsonSerializer.SerializeToElement(new { flag = false, value = (string?)null }), + }, + }, + }, + ExpirationTimeUtc = s_now.AddDays(2).UtcDateTime, + HistoryBinding = JsonSerializer.SerializeToElement(null), + Session = JsonSerializer.SerializeToElement(new { continuation = "session" }), + IngestedPositions = new Dictionary { ["producer"] = 2 }, + }, + }; + state.Data.ConversationHistory.Add(new DurableAgentStateResponse + { + CorrelationId = "request", + Messages = [new DurableAgentStateMessage { Role = "assistant", Contents = [new DurableAgentStateTextContent { Text = "not a delivery fallback" }] }], + }); + return Reload(state); + } + + private static string Serialize(DurableAgentState state) => + JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + private static DurableAgentState Reload(DurableAgentState state) => + JsonSerializer.Deserialize(Serialize(state), DurableAgentStateJsonContext.Default.DurableAgentState)!; + + private sealed class Clock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} 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..1d42e00 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentEntityTimeToLiveTests.cs @@ -0,0 +1,369 @@ +// 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 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..7f152f1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentRunHandleTests.cs @@ -0,0 +1,330 @@ +// 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); + state.MailboxWritesAuthorized = true; + string before = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + DurableAgentResultUnavailableException exception = + await Assert.ThrowsAsync( + () => CreateHandle(state).ReadAgentResponseAsync()); + + Assert.Equal("correlation", exception.CorrelationId); + Assert.Equal(completedAt, exception.CompletedAt); + Assert.Equal(before, JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState)); + Assert.Equal("available", state.Data.CompletionReceipts!["correlation"].ResultState); + Assert.Single(state.Data.TerminalResults!); + } + + [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); + } + + internal 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/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/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/LegacyPromotionMetadataTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/LegacyPromotionMetadataTests.cs new file mode 100644 index 0000000..2a7c693 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/LegacyPromotionMetadataTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; +using static Microsoft.Agents.AI.DurableTask.Tests.Unit.AgentEntityDeliveryTests; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit; + +public sealed class LegacyPromotionMetadataTests +{ + public static TheoryData LegacyValues + { + get + { + TheoryData cases = new(); + foreach (string version in new[] { "1.0.0", "1.1.0", "1.2.0" }) + { + foreach (string? value in new[] { null, "null", "false", "0", "\"\"", "{}", """{"nested":[null,false,0,""]}""" }) + { + cases.Add(version, value); + } + } + + return cases; + } + } + + [Theory] + [MemberData(nameof(LegacyValues))] + public async Task AuthorizedPromotionAndColdDuplicatesPreserveCanonicalMetadataAsync(string version, string? value) + { + const string Text = """{"value":"not the canonical value","haltRequested":true}"""; + string valueProperty = value is null ? string.Empty : $",\"value\":{value}"; + string json = """ + {"schemaVersion":"VERSION","data":{"conversationHistory":[ + {"$type":"response","correlationId":"correlation","createdAt":"2026-09-10T05:00:00Z", + "messages":[{"role":"assistant","messageId":"message","contents":[ + {"$type":"text","text":TEXT,"futureContent":{"zero":0}}]}], + "usage":{"inputTokenCount":0,"outputTokenCount":2,"totalTokenCount":2}, + "extensionData":{"null":null,"false":false,"zero":0,"empty":"","json":"{\"value\":false}"}, + "futureResponse":{"nested":[null,false,0,""]}VALUE_PROPERTY}]}} + """ + .Replace("VERSION", version, StringComparison.Ordinal) + .Replace("TEXT", JsonSerializer.Serialize(Text), StringComparison.Ordinal) + .Replace("VALUE_PROPERTY", valueProperty, StringComparison.Ordinal); + DurableAgentState legacy = JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)!; + AgentResponse polledLegacy = await AgentRunHandleTests.CreateHandle(legacy).ReadAgentResponseAsync(); + JsonElement expected = AssertTransport(polledLegacy); + Assert.Equal(Text, polledLegacy.Text); + Assert.Equal(value is not null, expected.TryGetProperty("value", out _)); + Assert.False(expected.GetProperty("extensionData").TryGetProperty("absent", out _)); + Assert.Equal(JsonValueKind.Null, expected.GetProperty("extensionData").GetProperty("null").ValueKind); + Assert.Equal(JsonValueKind.False, expected.GetProperty("extensionData").GetProperty("false").ValueKind); + Assert.Equal(0, expected.GetProperty("extensionData").GetProperty("zero").GetInt32()); + Assert.Equal(string.Empty, expected.GetProperty("extensionData").GetProperty("empty").GetString()); + Assert.Equal("""{"value":false}""", expected.GetProperty("extensionData").GetProperty("json").GetString()); + + RecordingAgent agent = new("agent"); + EntityHarness promotion = CreateHarness(agent, legacy, registerWithFactory: true, + onFactoryInvoked: () => Assert.Fail("Promotion must bypass the agent factory."), + onSignal: (_, _) => Assert.Fail("Promotion must not schedule a signal.")); + AgentResponse first = await promotion.RunAsync(new RunRequest([]) { CorrelationId = "correlation" }); + Assert.True(JsonElement.DeepEquals(expected, AssertTransport(first))); + + DurableAgentState committed = Assert.IsType(promotion.PersistedState); + Assert.Equal(DurableAgentState.RevisedSchemaVersion, committed.SchemaVersion); + Assert.Single(committed.Data.CompletionReceipts!); + Assert.Single(committed.Data.TerminalResults!); + DurableAgentStateResponse transcript = Assert.IsType( + Assert.Single(committed.Data.ConversationHistory)); + DurableAgentStateTerminalResponse snapshot = committed.Data.TerminalResults!["correlation"].Response!; + Assert.NotSame(transcript.ExtensionData, snapshot.AdditionalProperties); + Assert.NotSame(transcript.UnknownProperties, snapshot.UnknownProperties); + transcript.ExtensionData!["false"] = JsonSerializer.SerializeToElement(true); + transcript.UnknownProperties!["futureResponse"] = JsonSerializer.SerializeToElement("changed"); + transcript.Messages[0].MessageId = "changed"; + committed.Data.ConversationHistory.Clear(); + first.Messages.Clear(); + + for (int duplicateIndex = 0; duplicateIndex < 2; duplicateIndex++) + { + committed = Reload(committed); + string beforePoll = Serialize(committed); + AgentResponse polled = await AgentRunHandleTests.CreateHandle(committed).ReadAgentResponseAsync(); + Assert.Equal(beforePoll, Serialize(committed)); + Assert.True(JsonElement.DeepEquals(expected, AssertTransport(polled))); + Assert.Equal(Text, polled.Text); + Assert.False(Assert.IsType(polled.AdditionalProperties!["false"]).GetBoolean()); + polled.AdditionalProperties["false"] = "native mutation"; + + EntityHarness duplicate = CreateHarness(agent, committed, enableMailboxWrites: false, + registerWithFactory: true, + onFactoryInvoked: () => Assert.Fail("Duplicate must bypass the agent factory."), + onSignal: (_, _) => Assert.Fail("Duplicate must not schedule a signal.")); + AgentResponse response = await duplicate.RunAsync(new RunRequest([]) { CorrelationId = "correlation" }); + Assert.True(JsonElement.DeepEquals(expected, AssertTransport(response))); + Assert.Equal(Text, response.Text); + Assert.Equal(2, response.Usage!.TotalTokenCount); + Assert.Equal(JsonValueKind.False, Assert.IsType(response.AdditionalProperties!["false"]).ValueKind); + committed = Assert.IsType(duplicate.PersistedState); + Assert.Empty(committed.Data.ConversationHistory); + Assert.Single(committed.Data.CompletionReceipts!); + Assert.Single(committed.Data.TerminalResults!); + } + + Assert.Equal(0, agent.InvocationCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PromotionPreservesAbsentAndEmptyMetadataWithoutInventingValueAsync(bool emptyMetadata) + { + DurableAgentState legacy = new(); + legacy.Data.ConversationHistory.Add(new DurableAgentStateResponse + { + CorrelationId = "correlation", + CreatedAt = new DateTimeOffset(2026, 9, 10, 5, 0, 0, TimeSpan.Zero), + Messages = [DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.Assistant, "null") { MessageId = "message" })], + ExtensionData = emptyMetadata ? new Dictionary() : null, + }); + JsonElement expected = AssertTransport(await AgentRunHandleTests.CreateHandle(legacy).ReadAgentResponseAsync()); + EntityHarness promotion = CreateHarness(new RecordingAgent("agent"), legacy, + registerWithFactory: true, onFactoryInvoked: () => Assert.Fail("No model is needed.")); + AssertTransport(await promotion.RunAsync(new RunRequest([]) { CorrelationId = "correlation" })); + DurableAgentState state = Reload(Assert.IsType(promotion.PersistedState)); + JsonElement actual = AssertTransport(await AgentRunHandleTests.CreateHandle(state).ReadAgentResponseAsync()); + + Assert.True(JsonElement.DeepEquals(expected, actual), $"Expected: {expected}\nActual: {actual}"); + Assert.Equal(emptyMetadata, actual.TryGetProperty("extensionData", out _)); + Assert.False(actual.TryGetProperty("value", out _)); + } + + private static JsonElement AssertTransport(AgentResponse response) + { + JsonElement expected = Assert.IsType(response.GetDurableResult()); + JsonElement native = JsonSerializer.SerializeToElement( + response, DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentResponse))); + DurableDataConverter converter = new(); + AgentResponse restored = Assert.IsType( + converter.Deserialize(converter.Serialize(response), typeof(AgentResponse))); + Assert.True(JsonElement.DeepEquals(native, JsonSerializer.SerializeToElement( + restored, DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentResponse))))); + JsonElement retained = Assert.IsType(restored.GetDurableResult()); + Assert.True(JsonElement.DeepEquals(expected, retained)); + return retained; + } + + private static string Serialize(DurableAgentState state) => + JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + private static DurableAgentState Reload(DurableAgentState state) => + JsonSerializer.Deserialize(Serialize(state), DurableAgentStateJsonContext.Default.DurableAgentState)!; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs index 80df8d6..f322e8b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs @@ -75,6 +75,63 @@ public void LegacySnapshotsRejectV2OnlyMessageShapes(string 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]")] @@ -137,7 +194,7 @@ public void LegacyStateRoundTripsWithoutRevisedFields() } [Fact] - public void ProductionConverterRejectsRevisedStateUntilMailboxActivation() + public void ProductionReaderSupportsRevisedStateButPassiveDtosDoNotActivateNewWrites() { const string Json = """ { @@ -151,10 +208,12 @@ public void ProductionConverterRejectsRevisedStateUntilMailboxActivation() """; DurableAgentState state = Deserialize(Json); - Assert.Throws( - () => JsonSerializer.Deserialize( - Json, - DurableAgentStateJsonContext.Default.DurableAgentState)); + 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, 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..9f2d5ae 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,427 @@ 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\":[{}]", + "\"sentMessages\":[{\"future\":{\"typeName\":\"type\",\"data\":\"payload\"}}]", + "\"sentMessages\":[{\"typeName\":\"type\"}]", + "\"sentMessages\":[{\"data\":\"payload\"}]", + "\"sentMessages\":[{\"typeName\":null,\"data\":\"payload\"}]", + "\"sentMessages\":[{\"typeName\":\"\",\"data\":\"payload\"}]", + "\"sentMessages\":[{\"typeName\":\" \\t \",\"data\":\"payload\"}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":null}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":\"\"}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":\" \\t \"}]", + "\"sentMessages\":[{\"typeName\":{},\"data\":\"payload\"}]", + "\"sentMessages\":[{\"typeName\":[],\"data\":\"payload\"}]", + "\"sentMessages\":[{\"typeName\":false,\"data\":\"payload\"}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":false}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":0}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":[]}]", + "\"sentMessages\":[{\"typeName\":\"System.String\",\"data\":\"valid\"},{}]", + "\"sentMessages\":[{\"typeName\":\"type\",\"data\":\"first\",\"\\u0064ata\":\"second\"}]", + "\"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 + "}"); + if (invalidField.StartsWith("\"sentMessages\":[", StringComparison.Ordinal)) + { + data.Add("{\"result\":\"replacement\",\"stateUpdates\":{\"scope:key\":\"changed\",\"other:deleted\":null}," + + "\"clearedScopes\":[\"scope\"],\"events\":[\"must-not-escape\"],\"haltRequested\":true," + invalidField + "}"); + } + } + + 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))] + [MemberData(nameof(InvalidActivityResponses))] + 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))] + [MemberData(nameof(InvalidActivityResponses))] + 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":[{"typeName":"System.String","data":"trusted message","future":{"typeName":"changed","data":"changed"}}]}"""; - // Act - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(Response); + DurableExecutorOutput output = await DispatchActivityAsync(Response); - // Assert — the envelope deserializes with Result containing the original response - DurableExecutorOutput? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + 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.NotNull(parsed); - Assert.Equal(Response, parsed.Result); - Assert.False(parsed.HaltRequested); + [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); + + 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 - }); + const string Response = """{"result":"","stateUpdates":{},"clearedScopes":[],"events":[],"sentMessages":[],"haltRequested":false}"""; - // Act - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(response); + DurableExecutorOutput output = await DispatchActivityAsync(Response); - // Assert — the crafted payload is safely contained in Result, not interpreted as control fields - DurableExecutorOutput? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + AssertOpaque(string.Empty, output); + } - Assert.NotNull(parsed); - Assert.Equal(response, parsed.Result); + [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); - // 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); + 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 void CreateExecutorOutputEnvelope_EmptyString_ProducesValidEnvelope() + public async Task DispatchAsync_RealActivityContext_ProducesTrustedControlsAsync() { - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(string.Empty); + 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? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + DurableExecutorOutput output = await DispatchActivityAsync(activityResult); - Assert.NotNull(parsed); - Assert.Equal(string.Empty, parsed.Result); + 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_SpecialCharacters_ProperlyEscaped() + public async Task DispatchAsync_SubWorkflow_UsesTypedControlsAndOpaqueResultAsync() + { + 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 output = await DispatchAsync(context, new("child", false, SubWorkflow: workflow)); + + 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); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DispatchAsync_UnknownTypedNameIsStructurallyValidForBothTrustedBoundariesAsync(bool child) + { + const string Response = """{"result":"not a fallback","sentMessages":[{"typeName":"Future.Message, Future.Assembly","data":"{}"}],"events":["trusted event"],"haltRequested":true}"""; + DurableExecutorOutput output; + if (child) + { + Workflow workflow = new WorkflowBuilder(new FunctionExecutor("child", (input, _, _) => input)) + .WithName("child-workflow").Build(); + Mock context = new(); + DurableDataConverter converter = new(); + context.Setup(c => c.CallSubOrchestratorAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DurableWorkflowResult?)converter.Deserialize(Response, typeof(DurableWorkflowResult))); + output = await DispatchAsync(context, new("child", false, SubWorkflow: workflow)); + } + else + { + output = await DispatchActivityAsync(Response); + } + + Assert.Equal("not a fallback", output.Result); + TypedPayload message = Assert.Single(output.SentMessages); + Assert.Equal("Future.Message, Future.Assembly", message.TypeName); + Assert.Equal("{}", message.Data); + Assert.Equal(["trusted event"], output.Events); + Assert.True(output.HaltRequested); + } + + [Theory] + [InlineData("null")] + [InlineData("false")] + [InlineData("0")] + [InlineData("\"\"")] + [InlineData("\" \\t \"")] + public async Task DispatchAsync_RealTypedJsonScalarPayloadIsNotConfusedWithMissingDataAsync(string json) { - // Arrange — response with characters that need JSON escaping - const string Response = "Line1\nLine2\t\"quoted\" \\backslash"; + JsonScalarProducingExecutor executor = new(json); + Workflow workflow = new WorkflowBuilder(executor).Build(); + string activityResult = await DurableActivityExecutor.ExecuteAsync( + workflow.ReflectExecutors()[executor.Id], + JsonSerializer.Serialize(new DurableActivityInput { Input = "input" }, DurableWorkflowJsonContext.Default.DurableActivityInput)); + using JsonDocument wire = JsonDocument.Parse(activityResult); + JsonElement message = wire.RootElement.GetProperty("sentMessages")[0]; + Assert.Equal(JsonValueKind.String, message.GetProperty("typeName").ValueKind); + Assert.Equal(JsonValueKind.String, message.GetProperty("data").ValueKind); + Assert.Equal(json, message.GetProperty("data").GetString()); - // Act - string envelope = DurableExecutorDispatcher.CreateExecutorOutputEnvelope(Response); + DurableExecutorOutput output = await DispatchActivityAsync(activityResult); - // Assert — roundtrips correctly through deserialization - DurableExecutorOutput? parsed = JsonSerializer.Deserialize( - envelope, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + TypedPayload payload = Assert.Single(output.SentMessages); + Assert.Equal(typeof(JsonElement).AssemblyQualifiedName, payload.TypeName); + Assert.Equal(json, payload.Data); + Assert.Equal("\"trusted\"", output.StateUpdates["scope:key"]); + } + + [Fact] + public async Task DispatchAsync_NullSubWorkflowResult_ReturnsEmptyResultAsync() + { + 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); - Assert.NotNull(parsed); - Assert.Equal(Response, parsed.Result); + DurableExecutorOutput output = await DispatchAsync(context, new("child", false, SubWorkflow: workflow)); + + 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); + + 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 JsonScalarProducingExecutor(string json) : Executor("scalar") + { + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + using JsonDocument document = JsonDocument.Parse(json); + await context.SendMessageAsync(document.RootElement, cancellationToken: cancellationToken); + await context.QueueStateUpdateAsync("key", "trusted", "scope", cancellationToken); + } + } + + 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..41b4458 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowRunnerTrustBoundaryTests.cs @@ -0,0 +1,328 @@ +// 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, TypeName = typeof(string).AssemblyQualifiedName }], + 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(typeof(string).AssemblyQualifiedName, inputs[1].InputTypeName); + 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/SubWorkflowTypedRoutingTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/SubWorkflowTypedRoutingTests.cs new file mode 100644 index 0000000..298604c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/SubWorkflowTypedRoutingTests.cs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +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; + +public sealed class SubWorkflowTypedRoutingTests +{ + public static TheoryData ChildOutputs + { + get + { + TheoryData cases = new(); + foreach (string boundary in new[] { "activity", "agent", "request-port" }) + { + foreach (string text in new[] { "", " \t\r\n ", "ordinary text", "null", "false", "0", "\"\"", "{}", WorkflowExecutionTestHelper.ControlEnvelope }) + { + cases.Add(boundary, text); + } + } + + return cases; + } + } + + public static TheoryData InvalidChildMessages + { + get + { + TheoryData cases = new(); + string[] messages = + [ + "null", + "{}", + """{"data":"{}"}""", + """{"typeName":null,"data":"{}"}""", + """{"typeName":"","data":"{}"}""", + """{"typeName":" \t\r\n ","data":"{}"}""", + """{"typeName":"System.String"}""", + """{"typeName":"System.String","data":null}""", + """{"typeName":"System.String","data":""}""", + """{"typeName":"System.String","data":" \t\r\n "}""", + ]; + foreach (string message in messages) + { + // Invalid alone, first, middle and last: no valid subset may escape. + foreach (int position in new[] { -1, 0, 1, 2 }) + { + cases.Add(message, position, false); + cases.Add(message, position, true); + } + } + + return cases; + } + } + + [Theory] + [MemberData(nameof(InvalidChildMessages))] + public async Task InvalidChildCollectionRejectsAllMessagesAndControlsAfterColdReplayAsync( + string invalidMessage, int position, bool halt) + { + JsonArray messages = position < 0 + ? [] + : new JsonArray(Message(typeof(string), "must-not-route-first"), Message(typeof(string), "must-not-route-last")); + messages.Insert(Math.Max(position, 0), JsonNode.Parse(invalidMessage)); + string wire = ChildResult(messages, WorkflowExecutionTestHelper.ControlEnvelope, halt); + + await AssertOpaqueChildAndReplayAsync(wire, WorkflowExecutionTestHelper.ControlEnvelope); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t\r\n ")] + [InlineData("ordinary text")] + [InlineData("null")] + [InlineData("{}")] + public async Task InvalidChildCollectionPreservesExactResultAndEmptySemanticsAsync(string? text) + { + await AssertOpaqueChildAndReplayAsync(ChildResult(new JsonArray((JsonNode?)null), text, halt: true), text ?? ""); + } + + [Theory] + [InlineData("missing", false)] + [InlineData("missing", true)] + [InlineData("null", false)] + [InlineData("null", true)] + [InlineData("empty", false)] + [InlineData("empty", true)] + public async Task LegacyChildResultOnlyRetainsStringProvenanceAndTrustedControlsAsync(string collection, bool halt) + { + JsonObject result = JsonNode.Parse(ChildResult([], WorkflowExecutionTestHelper.ControlEnvelope, halt))!.AsObject(); + if (collection == "missing") + { + result.Remove("sentMessages"); + } + else if (collection == "null") + { + result["sentMessages"] = null; + } + + await AssertOpaqueChildAndReplayAsync(result.ToJsonString(), WorkflowExecutionTestHelper.ControlEnvelope, preserveEvent: true, halt); + } + + [Fact] + public async Task NullChildResultRemainsEmptyAfterColdReplayAsync() + { + await AssertOpaqueChildAndReplayAsync("null", ""); + } + + [Fact] + public async Task ValidChildCollectionRoutesEveryTypedValueInOrderAfterColdReplayAsync() + { + string[] jsonValues = ["null", "false", "0", "\"\"", "\" \"", """{"nested":[null,false,0,""]}"""]; + JsonArray messages = + [ + Message(typeof(Dictionary), "{}"), + Message(typeof(int), "0"), + Message(typeof(string), WorkflowExecutionTestHelper.ControlEnvelope), + ]; + foreach (string value in jsonValues) + { + messages.Add(Message(typeof(JsonElement), value)); + } + + RoutingHarness original = new("activity", "seed", maxSupersteps: messages.Count + 1, + childOutputWire: ChildResult(messages, "must-not-route-result", halt: false)); + DurableWorkflowResult result = await original.RunAsync(); + Assert.Equal([typeof(Dictionary), typeof(int), typeof(string), .. jsonValues.Select(_ => typeof(JsonElement))], + original.Successor.HandledTypes); + Assert.Equal(1, original.Successor.ObjectCalls); + Assert.Equal([0], original.Successor.IntegerInputs); + Assert.Equal([WorkflowExecutionTestHelper.ControlEnvelope], original.Successor.StringInputs); + Assert.Equal(jsonValues, original.Successor.JsonInputs); + Assert.Equal(jsonValues[^1], result.Result); + Assert.Contains("child-event", result.Events); + Assert.DoesNotContain("event", result.Events); + Assert.False(result.HaltRequested); + Assert.Equal(messages.Count, original.SuccessorInputs.Count); + for (int i = 0; i < messages.Count; i++) + { + Assert.Equal(messages[i]!["typeName"]!.GetValue(), original.SuccessorInputs[i].InputTypeName); + Assert.Equal(messages[i]!["data"]!.GetValue(), original.SuccessorInputs[i].Input); + Assert.Empty(original.SuccessorInputs[i].State); + } + + RoutingHarness replay = new("activity", "seed", maxSupersteps: messages.Count + 1, replayCalls: ColdHistory(original)); + Assert.Equal(Serialize(result), Serialize(await replay.RunAsync())); + Assert.Empty(replay.Successor.HandledTypes); + Assert.Equal(0, replay.ExecutedActivities); + replay.AssertHistoryConsumed(); + } + + [Theory] + [InlineData("Future.Message")] + [InlineData("Future.Message, Future.Assembly, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null")] + [InlineData(" Future.Message ")] + public async Task UnknownChildTypeFailsAtTargetWithoutChoosingFirstHandlerAfterColdReplayAsync(string typeName) + { + JsonArray messages = [new JsonObject { ["typeName"] = typeName, ["data"] = "{}" }]; + RoutingHarness original = new("activity", "seed", childOutputWire: ChildResult(messages, "not a fallback", halt: false)); + TaskFailedException failure = await Assert.ThrowsAsync(() => original.RunAsync()); + Assert.Equal(typeof(InvalidOperationException).FullName, failure.FailureDetails.ErrorType); + Assert.Contains(typeName, failure.FailureDetails.ErrorMessage, StringComparison.Ordinal); + Assert.Empty(original.Successor.HandledTypes); + DurableActivityInput input = Assert.Single(original.SuccessorInputs); + Assert.Equal(typeName, input.InputTypeName); + Assert.Equal("{}", input.Input); + + RoutingHarness replay = new("activity", "seed", replayCalls: ColdHistory(original)); + TaskFailedException repeated = await Assert.ThrowsAsync(() => replay.RunAsync()); + Assert.Equal(JsonSerializer.Serialize(failure.FailureDetails), JsonSerializer.Serialize(repeated.FailureDetails)); + Assert.Empty(replay.Successor.HandledTypes); + Assert.Equal(0, replay.ExecutedActivities); + replay.AssertHistoryConsumed(); + } + + [Theory] + [InlineData("""{"sentMessages":[42]}""")] + [InlineData("""{"sentMessages":[{"typeName":42,"data":"{}"}]}""")] + [InlineData("""{"sentMessages":[{"typeName":"System.String","data":{}}]}""")] + public async Task InvalidSdkJsonStillPropagatesSerializationFailureAsync(string wire) + { + RoutingHarness harness = new("activity", "seed", childOutputWire: wire); + await Assert.ThrowsAsync(() => harness.RunAsync()); + Assert.Empty(harness.Successor.HandledTypes); + Assert.Empty(harness.SuccessorInputs); + } + + private static JsonObject Message(Type type, string data) => new() { ["typeName"] = type.AssemblyQualifiedName, ["data"] = data }; + + private static string ChildResult(JsonArray messages, string? text, bool halt) => new JsonObject + { + ["result"] = text, + ["sentMessages"] = messages, + ["events"] = new JsonArray("child-event"), + ["haltRequested"] = halt, + ["stateUpdates"] = new JsonObject { ["scope:key"] = "must-not-escape" }, + ["clearedScopes"] = new JsonArray("scope"), + }.ToJsonString(); + + private static Dictionary> ColdHistory(RoutingHarness harness) => + JsonSerializer.Deserialize>>(JsonSerializer.Serialize(harness.Calls))!; + + private static async Task AssertOpaqueChildAndReplayAsync(string wire, string text, bool preserveEvent = false, bool halt = false) + { + RoutingHarness original = new("activity", "seed", childOutputWire: wire); + DurableWorkflowResult result = await original.RunAsync(); + AssertResult(result, text, halt); + bool routed = text.Length > 0 && !halt; + Assert.Equal(routed ? new[] { text } : [], original.Successor.StringInputs); + Assert.Equal(routed ? new[] { typeof(string) } : [], original.Successor.HandledTypes); + Assert.Equal(preserveEvent, result.Events.Contains("child-event")); + Assert.DoesNotContain("event", result.Events); + Assert.All(original.SuccessorInputs, input => + { + Assert.Equal(typeof(string).AssemblyQualifiedName, input.InputTypeName); + Assert.Equal(text, input.Input); + Assert.Empty(input.State); + }); + Assert.Equal(routed ? 1 : 0, original.SuccessorInputs.Count); + + RoutingHarness replay = new("activity", "seed", replayCalls: ColdHistory(original)); + Assert.Equal(Serialize(result), Serialize(await replay.RunAsync())); + Assert.Empty(replay.Successor.HandledTypes); + Assert.Equal(0, replay.ExecutedActivities); + replay.AssertHistoryConsumed(); + } + + [Theory] + [MemberData(nameof(ChildOutputs))] + public async Task ChildRunnerRoutesOpaqueStringToNonFirstHandlerAfterColdReplayAsync(string boundary, string text) + { + RoutingHarness original = new(boundary, text); + DurableWorkflowResult first = await original.RunAsync(); + AssertResult(first, text, halt: false); + Assert.Equal(text.Length == 0 ? [] : new[] { text }, original.Successor.StringInputs); + Assert.Equal(0, original.Successor.ObjectCalls); + Assert.Equal(1, original.ChildRuns); + Assert.Equal(text.Length == 0 ? 0 : 1, original.SuccessorInputs.Count); + if (text.Length > 0) + { + DurableActivityInput input = Assert.Single(original.SuccessorInputs); + Assert.Equal(typeof(string).AssemblyQualifiedName, input.InputTypeName); + Assert.Equal(text, input.Input); + Assert.Empty(input.State); + } + + // Reconstruct graphs, contexts and serialized history, then replay runner decisions. + // This models SDK call-result replay, not an actual backend/worker restart. + string history = JsonSerializer.Serialize(original.Calls); + RoutingHarness replay = new(boundary, text, + replayCalls: JsonSerializer.Deserialize>>(history)!); + DurableWorkflowResult repeated = await replay.RunAsync(); + AssertResult(repeated, text, halt: false); + Assert.Equal(Serialize(first), Serialize(repeated)); + Assert.Empty(replay.Successor.StringInputs); + Assert.Equal(0, replay.Successor.ObjectCalls); + Assert.Equal(0, replay.ExecutedActivities); + Assert.Equal(1, replay.ChildRuns); + Assert.Equal(original.SuccessorInputs.Count, replay.SuccessorInputs.Count); + replay.AssertHistoryConsumed(); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 2)] + public async Task ActualChildControlsRetainHaltAndSuperstepLimitsAsync(bool halt, int maxSupersteps) + { + RoutingHarness harness = new("activity", "child result", halt: halt, maxSupersteps: maxSupersteps); + if (!halt && maxSupersteps == 1) + { + await Assert.ThrowsAsync(() => harness.RunAsync()); + Assert.Empty(harness.Successor.StringInputs); + } + else + { + DurableWorkflowResult result = await harness.RunAsync(); + AssertResult(result, "child result", halt); + Assert.Equal(halt ? 0 : 1, harness.Successor.StringInputs.Count); + if (halt) + { + Assert.Contains(result.Events, item => JsonSerializer.Deserialize( + item, DurableWorkflowJsonContext.Default.TypedPayload)!.TypeName == typeof(DurableHaltRequestedEvent).AssemblyQualifiedName); + } + else + { + Assert.Empty(Assert.Single(harness.SuccessorInputs).State); + } + } + + Assert.Equal(1, harness.ChildRuns); + Assert.Equal(0, harness.Successor.ObjectCalls); + } + + private static void AssertResult(DurableWorkflowResult result, string text, bool halt) + { + Assert.Equal(text, result.Result); + Assert.Equal(halt, result.HaltRequested); + if (text.Length == 0) + { + Assert.Empty(result.SentMessages); + } + else + { + TypedPayload message = Assert.Single(result.SentMessages); + Assert.Equal(text, message.Data); + Assert.Equal(typeof(string).AssemblyQualifiedName, message.TypeName); + } + } + + private static string Serialize(DurableWorkflowResult result) => + JsonSerializer.Serialize(result, DurableWorkflowJsonContext.Default.DurableWorkflowResult); + + public sealed record RecordedCall(string Name, string Input, string? Output, TaskFailureDetails? Failure = null); + + private sealed class RoutingHarness + { + private readonly Workflow _parent; + private readonly Workflow _child; + private readonly string _text; + private readonly string? _childOutputWire; + private readonly DurableOptions _options = new(); + private readonly Dictionary>? _replay; + + public RoutingHarness(string boundary, string text, bool halt = false, int maxSupersteps = 2, + Dictionary>? replayCalls = null, string? childOutputWire = null) + { + this._text = text; + this._childOutputWire = childOutputWire; + this._replay = replayCalls?.ToDictionary(pair => pair.Key, pair => new Queue(pair.Value)); + ExecutorBinding childStart; + if (boundary == "agent") + { + Mock agent = new(); + agent.SetupGet(value => value.Name).Returns("child-agent"); + childStart = agent.Object; + } + else if (boundary == "request-port") + { + childStart = RequestPort.Create("child-port"); + } + else + { + childStart = new FunctionExecutor("child-activity", + async (_, context, cancellationToken) => + { + await context.QueueStateUpdateAsync("key", "child-only", "scope", cancellationToken); + if (halt) + { + await context.RequestHaltAsync(); + } + + return text; + }, outputTypes: [typeof(string)]); + } + + this._child = new WorkflowBuilder(childStart).WithName("routing-child").Build(); + ExecutorBinding childHost = this._child.BindAsExecutor("child-host"); + this._parent = new WorkflowBuilder(childHost).WithName("routing-parent") + .AddEdge(childHost, this.Successor).Build(); + Assert.Equal(typeof(Dictionary), this.Successor.InputTypes.First()); + Assert.Equal([typeof(Dictionary), typeof(int), typeof(string), typeof(JsonElement)], this.Successor.InputTypes); + this._options.Workflows.AddWorkflow(this._parent); + this._options.Workflows.AddWorkflow(this._child); + this._options.Workflows.MaxSupersteps = maxSupersteps; + } + + public MultiTypeSuccessor Successor { get; } = new(); + + public Dictionary> Calls { get; } = []; + + public List SuccessorInputs { get; } = []; + + public int ChildRuns { get; private set; } + + public int ExecutedActivities { get; private set; } + + public Task RunAsync() => + this.RunAsync(this._parent, new DurableWorkflowInput { Input = "seed" }); + + public void AssertHistoryConsumed() + { + Assert.NotNull(this._replay); + Assert.All(this._replay.Values, queue => Assert.Empty(queue)); + } + + private RecordedCall ReplayCall(string workflowName, string name, string input) + { + RecordedCall recorded = this._replay![workflowName].Dequeue(); + Assert.Equal(recorded.Name, name); + Assert.Equal(recorded.Input, input); + if (recorded.Failure is not null) + { + throw new TaskFailedException(name, 0, recorded.Failure); + } + + return recorded; + } + + private void RecordCall(string workflowName, RecordedCall call) + { + if (!this.Calls.TryGetValue(workflowName, out List? calls)) + { + this.Calls[workflowName] = calls = []; + } + + calls.Add(call); + } + + private async Task RunAsync(Workflow workflow, DurableWorkflowInput input) + { + Mock context = WorkflowExecutionTestHelper.CreateAgentContext(this._text); + context.SetupGet(value => value.Name).Returns(WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!)); + context.SetupGet(value => value.InstanceId).Returns(workflow.Name!); + context.SetupGet(value => value.IsReplaying).Returns(this._replay is not null); + context.Setup(value => value.WaitForExternalEvent("child-port", It.IsAny())) + .ReturnsAsync(this._text); + Dictionary bindings = workflow.ReflectExecutors().Values.ToDictionary( + binding => WorkflowNamingHelper.ToOrchestrationFunctionName(WorkflowNamingHelper.GetExecutorName(binding.Id))); + context.Setup(value => value.CallActivityAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (TaskName name, object? activityInput, TaskOptions? _) => + { + string wire = Assert.IsType(activityInput); + ExecutorBinding binding = bindings[name.ToString()]; + if (binding.Id == this.Successor.Id) + { + this.SuccessorInputs.Add(JsonSerializer.Deserialize(wire, DurableWorkflowJsonContext.Default.DurableActivityInput)!); + } + + if (this._replay is not null) + { + return this.ReplayCall(workflow.Name!, name.ToString(), wire).Output!; + } + + this.ExecutedActivities++; + try + { + string output = await DurableActivityExecutor.ExecuteAsync(binding, wire); + this.RecordCall(workflow.Name!, new RecordedCall(name.ToString(), wire, output)); + return output; + } + catch (InvalidOperationException exception) + { + TaskFailureDetails failure = TaskFailureDetails.FromException(exception); + this.RecordCall(workflow.Name!, new RecordedCall(name.ToString(), wire, null, failure)); + throw new TaskFailedException(name.ToString(), 0, failure); + } + }); + context.Setup(value => value.CallSubOrchestratorAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (TaskName name, object? childInput, TaskOptions? _) => + { + Assert.Equal(WorkflowNamingHelper.ToOrchestrationFunctionName(this._child.Name!), name.ToString()); + this.ChildRuns++; + DurableDataConverter converter = new(); + string inputWire = converter.Serialize(childInput)!; + DurableWorkflowInput restoredInput = Assert.IsType>( + converter.Deserialize(inputWire, typeof(DurableWorkflowInput))); + DurableWorkflowResult childResult = await this.RunAsync(this._child, restoredInput); + string outputWire; + if (this._replay is not null) + { + outputWire = this.ReplayCall(workflow.Name!, name.ToString(), inputWire).Output!; + } + else + { + outputWire = this._childOutputWire ?? converter.Serialize(childResult); + this.RecordCall(workflow.Name!, new RecordedCall(name.ToString(), inputWire, outputWire)); + } + + return (DurableWorkflowResult?)converter.Deserialize(outputWire, typeof(DurableWorkflowResult)); + }); + DurableWorkflowResult result = await new DurableWorkflowRunner(this._options).RunWorkflowOrchestrationAsync( + context.Object, input, NullLogger.Instance); + string resultWire = Serialize(result); + if (this._replay is not null) + { + Assert.Equal(this.ReplayCall(workflow.Name!, "$result", "").Output, resultWire); + } + else + { + this.RecordCall(workflow.Name!, new RecordedCall("$result", "", resultWire)); + } + + return JsonSerializer.Deserialize(resultWire, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!; + } + } + + private sealed class MultiTypeSuccessor() : Executor("successor") + { + public List StringInputs { get; } = []; + + public int ObjectCalls { get; private set; } + + public List IntegerInputs { get; } = []; + + public List JsonInputs { get; } = []; + + public List HandledTypes { get; } = []; + + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes(routes => routes + .AddHandler, string>((_, _) => + { + this.ObjectCalls++; + this.HandledTypes.Add(typeof(Dictionary)); + return "incorrectly decoded"; + }) + .AddHandler((number, _) => + { + this.HandledTypes.Add(typeof(int)); + this.IntegerInputs.Add(number); + return JsonSerializer.Serialize(number); + }) + .AddHandler((text, _) => + { + this.HandledTypes.Add(typeof(string)); + this.StringInputs.Add(text); + return text; + }) + .AddHandler((json, _) => + { + this.HandledTypes.Add(typeof(JsonElement)); + this.JsonInputs.Add(json.GetRawText()); + return json.GetRawText(); + })); + } +} 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"));