From fa83107a9ccb33cbd982e5407ff2be08c1834d59 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Tue, 8 Sep 2026 17:50:32 +0300 Subject: [PATCH 1/6] Preserve durable agent state schema 1.2 compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 532fe4f5-939b-4962-989d-a1883dccd283 --- .../Microsoft.Agents.AI.DurableTask/Logs.cs | 9 + .../State/DurableAgentState.cs | 57 +- .../State/DurableAgentStateCompaction.cs | 12 + .../State/DurableAgentStateContent.cs | 10 +- .../State/DurableAgentStateData.cs | 37 +- .../State/DurableAgentStateEntry.cs | 18 +- .../State/DurableAgentStateErrorContent.cs | 15 +- .../State/DurableAgentStateErrorResponse.cs | 12 + .../State/DurableAgentStateJsonContext.cs | 3 + .../State/DurableAgentStateJsonConverter.cs | 90 ++- .../State/DurableAgentStateMessage.cs | 46 +- .../State/DurableAgentStateMessageIdentity.cs | 60 ++ .../State/DurableAgentStateRequest.cs | 21 +- .../State/DurableAgentStateResponse.cs | 72 +- .../State/DurableAgentStateSchemaVersion.cs | 111 +++ .../State/DurableAgentStateTruncation.cs | 29 + .../State/DurableAgentStateUnknownContent.cs | 688 +++++++++++++++++- .../State/DurableAgentStateUsage.cs | 40 +- .../State/README.md | 34 +- ...oft.Agents.AI.DurableTask.UnitTests.csproj | 6 + .../State/DurableAgentStateContentTests.cs | 485 +++++++++++- .../State/DurableAgentStateMessageTests.cs | 212 ++++++ .../State/DurableAgentStateResponseTests.cs | 147 +++- .../State/DurableAgentStateTests.cs | 392 +++++++++- 24 files changed, 2514 insertions(+), 92 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs index 2dd1e2a..077df3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -101,6 +101,15 @@ public static partial void LogTTLExpirationTimeCleared( this ILogger logger, AgentSessionId sessionId); + [LoggerMessage( + EventId = 16, + Level = LogLevel.Warning, + Message = "Unknown AI content metadata with runtime type '{RuntimeType}' could not be serialized. The value was omitted from durable state with failure category '{FailureCategory}'.")] + public static partial void LogUnknownContentSerializationFallback( + this ILogger logger, + string runtimeType, + string failureCategory); + // Durable workflow logs (EventIds 100-199) [LoggerMessage( diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs index 35aef33..1bcf6fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Agents.AI.DurableTask.State; @@ -10,6 +11,10 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonConverter(typeof(DurableAgentStateJsonConverter))] internal sealed class DurableAgentState { + internal const string CurrentSchemaVersion = "1.2.0"; + private static readonly DurableAgentStateSchemaVersion s_currentSchemaVersion = + DurableAgentStateSchemaVersion.ParseSupported(CurrentSchemaVersion); + /// /// Gets the data of the durable agent. /// @@ -20,8 +25,56 @@ internal sealed class DurableAgentState /// Gets the schema version of the durable agent state. /// /// - /// The version is specified in semver (i.e. "major.minor.patch") format. + /// New states default to . Deserialization assigns the + /// persisted value through this init-only property, and constructs a new + /// state when an older compatible version must be promoted for a write. Future compatible + /// versions are preserved rather than rewritten. /// [JsonPropertyName("schemaVersion")] - public string SchemaVersion { get; init; } = "1.1.0"; + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// + /// Gets application-defined root extension metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown root properties that are outside the declared schema. + /// + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + /// + /// Creates an independent copy suitable for an atomic entity operation. + /// + public DurableAgentState Clone() + { + 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."); + DurableAgentStateMessageIdentity.EnsureMessageIds(clone.Data.ConversationHistory); + + return new DurableAgentState + { + SchemaVersion = SelectSchemaVersionForWrite(clone.SchemaVersion), + Data = clone.Data, + ExtensionData = clone.ExtensionData, + UnknownProperties = clone.UnknownProperties, + }; + } + + private static string SelectSchemaVersionForWrite(string schemaVersion) + { + DurableAgentStateSchemaVersion sourceVersion = + DurableAgentStateSchemaVersion.ParseSupported(schemaVersion); + return sourceVersion.CompareTo(s_currentSchemaVersion) < 0 + ? CurrentSchemaVersion + : schemaVersion; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs new file mode 100644 index 0000000..578ae38 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a compacted transcript message written by another durable agent implementation. +/// +/// +/// This layer serializes, deserializes, and converts the shared compaction contract. Agent entity +/// replay and retention integration are deferred to later layers. +/// +internal sealed class DurableAgentStateCompaction : DurableAgentStateEntry; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs index 3ae7d12..015e5f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -42,10 +43,10 @@ internal abstract class DurableAgentStateContent JsonSerializer.SerializeToElement(value: null, jsonTypeInfo: s_objectTypeInfo); /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets unknown content properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Converts this durable agent state content to an . @@ -57,8 +58,9 @@ internal abstract class DurableAgentStateContent /// Creates a from an . /// /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original . - public static DurableAgentStateContent FromAIContent(AIContent content) + public static DurableAgentStateContent FromAIContent(AIContent content, ILogger? logger = null) { return content switch { @@ -72,7 +74,7 @@ public static DurableAgentStateContent FromAIContent(AIContent content) TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent), UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent), UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent), - _ => DurableAgentStateUnknownContent.FromUnknownContent(content) + _ => DurableAgentStateUnknownContent.FromUnknownContent(content, logger) }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index 745f619..5476176 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -17,16 +17,49 @@ internal sealed class DurableAgentStateData [JsonPropertyName("conversationHistory")] public IList ConversationHistory { get; init; } = []; + /// + /// Gets or sets the serialized inner agent session. + /// + [JsonPropertyName("session")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Session { get; set; } + + /// + /// Gets or sets the highest workflow conversation position ingested from each executor. + /// + /// + /// The .NET workflow path does not populate these watermarks yet, but they are preserved for + /// cross-language schema compatibility. + /// + [JsonPropertyName("ingestedPositions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? IngestedPositions { get; set; } + + /// + /// Gets or sets bounded evidence that retention removed conversation messages. + /// + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTruncation? Truncation { get; set; } + /// /// Gets or sets the expiration time (UTC) for this agent entity. /// If the entity is idle beyond this time, it will be automatically deleted. /// [JsonPropertyName("expirationTimeUtc")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? ExpirationTimeUtc { get; set; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets application-defined data-level metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown data properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs index 2f04c90..dc75542 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs @@ -12,6 +12,8 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(DurableAgentStateRequest), "request")] [JsonDerivedType(typeof(DurableAgentStateResponse), "response")] +[JsonDerivedType(typeof(DurableAgentStateErrorResponse), "errorResponse")] +[JsonDerivedType(typeof(DurableAgentStateCompaction), "compaction")] internal abstract class DurableAgentStateEntry { /// @@ -19,10 +21,11 @@ internal abstract class DurableAgentStateEntry /// /// /// This ID is used to correlate back to its - /// . + /// . Compaction entries do not have a correlation ID. /// [JsonPropertyName("correlationId")] - public required string CorrelationId { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CorrelationId { get; init; } /// /// Gets the timestamp when this entry was created. @@ -37,8 +40,15 @@ internal abstract class DurableAgentStateEntry public IReadOnlyList Messages { get; init; } = []; /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets application-defined entry metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown entry properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs index 17e5fea..833e4e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -29,7 +30,7 @@ internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent /// [JsonPropertyName("details")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Details { get; init; } + public JsonElement? Details { get; init; } /// /// Creates a from an . @@ -41,7 +42,11 @@ public static DurableAgentStateErrorContent FromErrorContent(ErrorContent conten { return new DurableAgentStateErrorContent() { - Details = content.Details, + Details = content.Details is null + ? null + : JsonSerializer.SerializeToElement( + content.Details, + DurableAgentStateJsonContext.Default.String), ErrorCode = content.ErrorCode, Message = content.Message }; @@ -52,7 +57,11 @@ public override AIContent ToAIContent() { return new ErrorContent(this.Message) { - Details = this.Details, + Details = this.Details is JsonElement details + ? details.ValueKind == JsonValueKind.String + ? details.GetString() + : details.GetRawText() + : null, ErrorCode = this.ErrorCode }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs new file mode 100644 index 0000000..bb5742c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a failed turn recorded by another durable agent implementation. +/// +/// +/// .NET durable agents do not currently create pollable error responses, but preserve this shared-schema +/// entry kind when reading state written by another language implementation. +/// +internal sealed class DurableAgentStateErrorResponse : DurableAgentStateResponse; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index 4ad9a62..87a8dc5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -11,7 +11,10 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(DurableAgentStateContent))] [JsonSerializable(typeof(DurableAgentStateData))] [JsonSerializable(typeof(DurableAgentStateEntry))] +[JsonSerializable(typeof(DurableAgentStateErrorResponse))] +[JsonSerializable(typeof(DurableAgentStateCompaction))] [JsonSerializable(typeof(DurableAgentStateMessage))] +[JsonSerializable(typeof(DurableAgentStateTruncation))] // Function call and result content [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index 4c7796b..17fd8d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -12,6 +12,7 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter public override DurableAgentState? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -30,15 +31,10 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter? extensionData = + element.Value.TryGetProperty(ExtensionDataPropertyName, out JsonElement extensionDataElement) + ? ReadExtensionData(extensionDataElement) + : null; + Dictionary? unknownProperties = null; + foreach (JsonProperty property in element.Value.EnumerateObject()) + { + if (property.NameEquals(SchemaVersionPropertyName) || + property.NameEquals(DataPropertyName) || + property.NameEquals(ExtensionDataPropertyName)) + { + continue; + } + + unknownProperties ??= []; + unknownProperties[property.Name] = property.Value.Clone(); + } return new DurableAgentState { - SchemaVersion = schemaVersion.ToString(), - Data = data ?? new DurableAgentStateData() + SchemaVersion = schemaVersionText!, + Data = data ?? new DurableAgentStateData(), + ExtensionData = extensionData, + UnknownProperties = unknownProperties, }; } /// public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options) { + _ = DurableAgentStateSchemaVersion.ParseSupported(value.SchemaVersion); + writer.WriteStartObject(); writer.WritePropertyName(SchemaVersionPropertyName); writer.WriteStringValue(value.SchemaVersion); @@ -66,6 +83,57 @@ public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonS writer, value.Data, DurableAgentStateJsonContext.Default.DurableAgentStateData); + if (value.ExtensionData is not null) + { + writer.WritePropertyName(ExtensionDataPropertyName); + WriteExtensionData(writer, value.ExtensionData); + } + + if (value.UnknownProperties is not null) + { + foreach ((string propertyName, JsonElement propertyValue) in value.UnknownProperties) + { + if (propertyName is not SchemaVersionPropertyName and + not DataPropertyName and + not ExtensionDataPropertyName) + { + writer.WritePropertyName(propertyName); + propertyValue.WriteTo(writer); + } + } + } + + writer.WriteEndObject(); + } + + private static Dictionary? ReadExtensionData(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The durable agent state 'extensionData' property must be an object."); + } + + return element.EnumerateObject().ToDictionary( + property => property.Name, + property => property.Value.Clone()); + } + + private static void WriteExtensionData( + Utf8JsonWriter writer, + IDictionary extensionData) + { + writer.WriteStartObject(); + foreach ((string propertyName, JsonElement propertyValue) in extensionData) + { + writer.WritePropertyName(propertyName); + propertyValue.WriteTo(writer); + } + writer.WriteEndObject(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index 294453c..3810486 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -25,6 +26,20 @@ internal sealed class DurableAgentStateMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? CreatedAt { get; init; } + /// + /// Gets the stable message identifier. + /// + [JsonPropertyName("messageId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MessageId { get; set; } + + /// + /// Gets message-level additional properties from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? AdditionalProperties { get; init; } + /// /// Gets the contents of this message. /// @@ -38,24 +53,39 @@ internal sealed class DurableAgentStateMessage public required string Role { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets unknown message properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . /// /// The to convert. + /// The stable identifier to use when the message does not already have one. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original message. - public static DurableAgentStateMessage FromChatMessage(ChatMessage message) + public static DurableAgentStateMessage FromChatMessage( + ChatMessage message, + string? generatedMessageId = null, + ILogger? logger = null) { + Dictionary? additionalProperties = message.AdditionalProperties? + .ToDictionary( + pair => pair.Key, + pair => JsonSerializer.SerializeToElement( + pair.Value, + DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)))); + return new DurableAgentStateMessage() { CreatedAt = message.CreatedAt, AuthorName = message.AuthorName, + MessageId = message.MessageId ?? generatedMessageId, + AdditionalProperties = additionalProperties, Role = message.Role.ToString(), - Contents = message.Contents.Select(DurableAgentStateContent.FromAIContent).ToList() + Contents = message.Contents.Select(content => + DurableAgentStateContent.FromAIContent(content, logger)).ToList() }; } @@ -65,10 +95,18 @@ public static DurableAgentStateMessage FromChatMessage(ChatMessage message) /// A representing this message. public ChatMessage ToChatMessage() { + AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null + ? null + : new AdditionalPropertiesDictionary( + this.AdditionalProperties.Select(pair => + new KeyValuePair(pair.Key, pair.Value))); + return new ChatMessage() { CreatedAt = this.CreatedAt, AuthorName = this.AuthorName, + MessageId = this.MessageId, + AdditionalProperties = additionalProperties, Contents = this.Contents.Select(c => c.ToAIContent()).ToList(), Role = new(this.Role) }; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs new file mode 100644 index 0000000..2b9e03a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Assigns deterministic identities to durable messages that predate schema 1.2. +/// +internal static class DurableAgentStateMessageIdentity +{ + public static void EnsureMessageIds(IEnumerable history) + { + foreach (DurableAgentStateEntry entry in history) + { + string entryType = entry switch + { + DurableAgentStateErrorResponse => "errorResponse", + DurableAgentStateRequest => "request", + DurableAgentStateResponse => "response", + DurableAgentStateCompaction => "compaction", + _ => throw new InvalidOperationException( + $"Unsupported durable agent state entry type '{entry.GetType()}'."), + }; + + for (int index = 0; index < entry.Messages.Count; index++) + { + DurableAgentStateMessage message = entry.Messages[index]; + if (message.MessageId is null) + { + message.MessageId = Create(entryType, entry.CorrelationId, entry.CreatedAt, index); + } + } + } + } + + public static string Create( + string entryType, + string? correlationId, + DateTimeOffset createdAt, + int storedIndex) + { + string scope = string.IsNullOrEmpty(correlationId) + ? FormatPythonIsoTimestamp(createdAt) + : correlationId; + return $"durable_{entryType}_{scope}_{storedIndex}"; + } + + internal static string FormatPythonIsoTimestamp(DateTimeOffset timestamp) + { + long microseconds = timestamp.Ticks % TimeSpan.TicksPerSecond / 10; + string fraction = microseconds == 0 + ? string.Empty + : $".{microseconds.ToString("D6", CultureInfo.InvariantCulture)}"; + return string.Concat( + timestamp.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture), + fraction, + timestamp.ToString("zzz", CultureInfo.InvariantCulture)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs index 6349b97..863e7fa 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -15,6 +16,7 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// Gets the ID of the orchestration that initiated this request (if any). /// [JsonPropertyName("orchestrationId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? OrchestrationId { get; init; } /// @@ -24,6 +26,7 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// If omitted, the expectation is that the agent will respond in plain text. /// [JsonPropertyName("responseType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ResponseType { get; init; } /// @@ -41,15 +44,27 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// Creates a from a . /// /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original request. - public static DurableAgentStateRequest FromRunRequest(RunRequest request) + public static DurableAgentStateRequest FromRunRequest( + RunRequest request, + ILogger? logger = null) { + DateTimeOffset createdAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow; return new DurableAgentStateRequest() { CorrelationId = request.CorrelationId, OrchestrationId = request.OrchestrationId, - Messages = request.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), - CreatedAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, + Messages = request.Messages.Select( + (message, index) => DurableAgentStateMessage.FromChatMessage( + message, + DurableAgentStateMessageIdentity.Create( + "request", + request.CorrelationId, + createdAt, + index), + logger)).ToList(), + CreatedAt = createdAt, ResponseType = request.ResponseFormat is ChatResponseFormatJson ? "json" : "text", ResponseSchema = (request.ResponseFormat as ChatResponseFormatJson)?.Schema }; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs index fb9f23d..5114953 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -2,13 +2,14 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; /// /// Represents a durable agent state entry that is a response from the agent. /// -internal sealed class DurableAgentStateResponse : DurableAgentStateEntry +internal class DurableAgentStateResponse : DurableAgentStateEntry { /// /// Gets the usage details for this state response. @@ -22,21 +23,42 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry /// /// The correlation ID linking this response to its request. /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original response. - public static DurableAgentStateResponse FromResponse(string correlationId, AgentResponse response) + public static DurableAgentStateResponse FromResponse( + string correlationId, + AgentResponse response, + ILogger? logger = null) { + List messages = response.Messages.ToList(); + DateTimeOffset createdAt = response.CreatedAt ?? GetCreatedAt(messages); return new DurableAgentStateResponse() { CorrelationId = correlationId, - CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, - Messages = response.Messages - .Where(HasSerializableContent) - .Select(DurableAgentStateMessage.FromChatMessage) - .ToList(), + CreatedAt = createdAt, + Messages = CreateStoredMessages(messages, correlationId, createdAt, logger), Usage = DurableAgentStateUsage.FromUsage(response.Usage) }; } + /// + /// Creates a response entry from response messages before aggregate response metadata is available. + /// + public static DurableAgentStateResponse FromMessages( + string correlationId, + IEnumerable messages, + ILogger? logger = null) + { + List messageList = messages.ToList(); + DateTimeOffset createdAt = GetCreatedAt(messageList); + return new DurableAgentStateResponse() + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = CreateStoredMessages(messageList, correlationId, createdAt, logger), + }; + } + /// /// Converts this back to an . /// @@ -51,17 +73,31 @@ public AgentResponse ToResponse() }; } - // Checks whether a ChatMessage has any content that will produce meaningful serialized data. - // Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable. - // Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and - // AdditionalProperties. We keep the message if any base AIContent has annotations or additional - // properties set. NOTE: if AIContent gains new serializable properties in the future, this check - // should be updated accordingly. - private static bool HasSerializableContent(ChatMessage message) + private static List CreateStoredMessages( + IEnumerable messages, + string correlationId, + DateTimeOffset createdAt, + ILogger? logger) + { + return messages + .Select((message, storedIndex) => DurableAgentStateMessage.FromChatMessage( + message, + DurableAgentStateMessageIdentity.Create( + "response", + correlationId, + createdAt, + storedIndex), + logger)) + .ToList(); + } + + private static DateTimeOffset GetCreatedAt(IReadOnlyList messages) { - return message.Contents.Any(c => - c.GetType() != typeof(AIContent) || - c.Annotations?.Count > 0 || - c.AdditionalProperties?.Count > 0); + return messages + .Select(message => message.CreatedAt) + .Where(createdAt => createdAt.HasValue) + .Select(createdAt => createdAt!.Value) + .DefaultIfEmpty(DateTimeOffset.UtcNow) + .Max(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs new file mode 100644 index 0000000..d156ca5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Numerics; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Centralizes strict parsing, supported-major validation, and ordering for durable state versions. +/// +internal readonly record struct DurableAgentStateSchemaVersion(BigInteger Major, BigInteger Minor, BigInteger Patch) + : IComparable +{ + private const int SupportedMajorVersion = 1; + + /// + /// Parses and validates a supported durable agent state schema version. + /// + public static DurableAgentStateSchemaVersion ParseSupported(string? value) + { + if (!TryParse(value, out DurableAgentStateSchemaVersion version)) + { + throw new InvalidOperationException("The durable agent state has an invalid 'schemaVersion' property."); + } + + if (version.Major != SupportedMajorVersion) + { + throw new InvalidOperationException($"The durable agent state schema version '{value}' is not supported."); + } + + return version; + } + + /// + /// Parses the schema's strict numeric major.minor.patch grammar. + /// + public static bool TryParse(string? value, out DurableAgentStateSchemaVersion version) + { + version = default; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + ReadOnlySpan remaining = value.AsSpan(); + if (!TryReadComponent(ref remaining, out BigInteger major) || + !TryReadComponent(ref remaining, out BigInteger minor) || + !TryReadFinalComponent(remaining, out BigInteger patch)) + { + return false; + } + + version = new(major, minor, patch); + return true; + } + + /// + public int CompareTo(DurableAgentStateSchemaVersion other) + { + int majorComparison = this.Major.CompareTo(other.Major); + if (majorComparison != 0) + { + return majorComparison; + } + + int minorComparison = this.Minor.CompareTo(other.Minor); + return minorComparison != 0 + ? minorComparison + : this.Patch.CompareTo(other.Patch); + } + + private static bool TryReadComponent(ref ReadOnlySpan value, out BigInteger component) + { + int separatorIndex = value.IndexOf('.'); + if (separatorIndex <= 0 || + !TryParseNumericIdentifier(value[..separatorIndex], out component)) + { + component = default; + return false; + } + + value = value[(separatorIndex + 1)..]; + return true; + } + + private static bool TryReadFinalComponent(ReadOnlySpan value, out BigInteger component) + { + component = default; + return value.IndexOf('.') < 0 && TryParseNumericIdentifier(value, out component); + } + + private static bool TryParseNumericIdentifier(ReadOnlySpan value, out BigInteger component) + { + component = 0; + if (value.IsEmpty || (value.Length > 1 && value[0] == '0')) + { + return false; + } + + foreach (char character in value) + { + if (character is < '0' or > '9') + { + return false; + } + + component = (component * 10) + (character - '0'); + } + + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs new file mode 100644 index 0000000..c71b9d5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Bounded evidence that durable conversation entries were removed by retention. +/// +internal sealed class DurableAgentStateTruncation +{ + /// + /// Gets or sets the total number of messages removed over the lifetime of the session. + /// + [JsonPropertyName("evictedMessageCount")] + public int EvictedMessageCount { get; set; } + + /// + /// Gets or sets when the first eviction occurred. + /// + [JsonPropertyName("firstEvictedAt")] + public DateTimeOffset FirstEvictedAt { get; set; } + + /// + /// Gets or sets when the latest eviction occurred. + /// + [JsonPropertyName("lastEvictedAt")] + public DateTimeOffset LastEvictedAt { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs index 00a180b..05bd4e4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -11,6 +15,19 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent { + private const string DurableEnvelopePropertyName = "$microsoftAgentFrameworkDurableTask"; + private const string KindPropertyName = "kind"; + private const string VersionPropertyName = "version"; + private const string AnnotationsPropertyName = "annotations"; + private const string AdditionalPropertiesPropertyName = "additionalProperties"; + private const string RawRepresentationPropertyName = "rawRepresentation"; + private const string AnnotatedRegionsPropertyName = "annotatedRegions"; + private const string OmittedPropertyName = "omitted"; + private const string UnknownContentKind = "unknownAIContent"; + private const int DurableEnvelopeVersion = 1; + + private static readonly JsonElement s_minimalUnknownContent = CreateMinimalUnknownContent(); + /// /// Gets the serialized unknown content. /// @@ -21,23 +38,678 @@ internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent /// Creates a from an . /// /// The to convert. + /// The logger used to report safe serialization fallbacks. /// A representing the original content. - public static DurableAgentStateUnknownContent FromUnknownContent(AIContent content) + public static DurableAgentStateUnknownContent FromUnknownContent( + AIContent content, + ILogger? logger = null) { - return new DurableAgentStateUnknownContent() + ArgumentNullException.ThrowIfNull(content); + + if (TryGetOpaqueContent(content, logger, out JsonElement opaqueContent)) + { + return new DurableAgentStateUnknownContent { Content = opaqueContent }; + } + + JsonObject envelope = CreateEnvelope(UnknownContentKind); + JsonObject omissions = []; + + AddAnnotations(content.Annotations, envelope, omissions, logger); + AddAdditionalProperties(content.AdditionalProperties, envelope, omissions, logger); + AddRawRepresentation(content.RawRepresentation, envelope, omissions, logger); + + if (omissions.Count > 0) + { + envelope[OmittedPropertyName] = omissions; + } + + return new DurableAgentStateUnknownContent { - Content = JsonSerializer.SerializeToElement( - value: content, - jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) + Content = SerializeEnvelope(envelope, content, logger), }; } /// public override AIContent ToAIContent() { - AIContent? content = this.Content.Deserialize( - jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) as AIContent; + if (TryGetEnvelope(this.Content, out JsonElement envelope, out string? kind) && + kind == UnknownContentKind && + TryReadUnknownContent(envelope, out AIContent unknownContent)) + { + return unknownContent; + } + + return CreateOpaqueAIContent(this.Content); + } + + private static JsonObject CreateEnvelope(string kind) + { + return new JsonObject + { + [KindPropertyName] = kind, + [VersionPropertyName] = DurableEnvelopeVersion, + }; + } + + private static JsonElement CreateMinimalUnknownContent() + { + JsonObject root = new() + { + [DurableEnvelopePropertyName] = CreateEnvelope(UnknownContentKind), + }; + return JsonSerializer.SerializeToElement( + root, + DurableAgentStateJsonContext.Default.JsonObject); + } + + private static JsonElement SerializeEnvelope( + JsonObject envelope, + AIContent source, + ILogger? logger) + { + JsonObject root = new() + { + [DurableEnvelopePropertyName] = envelope, + }; + + try + { + return JsonSerializer.SerializeToElement( + root, + DurableAgentStateJsonContext.Default.JsonObject); + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, source, exception); + return s_minimalUnknownContent.Clone(); + } + } + + private static bool TryGetOpaqueContent( + AIContent content, + ILogger? logger, + out JsonElement opaqueContent) + { + opaqueContent = default; + try + { + if (content.GetType() != typeof(AIContent) || + content.Annotations is { Count: > 0 } || + content.RawRepresentation is not JsonElement rawRepresentation || + content.AdditionalProperties is not { Count: 1 } additionalProperties || + !additionalProperties.TryGetValue("content", out object? storedContent) || + storedContent is not JsonElement storedElement) + { + return false; + } + + if (rawRepresentation.GetRawText() != storedElement.GetRawText()) + { + return false; + } + + opaqueContent = rawRepresentation.Clone(); + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, content, exception); + return false; + } + } + + private static void AddAnnotations( + IList? annotations, + JsonObject envelope, + JsonObject omissions, + ILogger? logger) + { + if (annotations is null) + { + return; + } + + if (!TryGetCount(annotations, logger, out int count)) + { + omissions[AnnotationsPropertyName] = true; + return; + } + + JsonArray projection = []; + int omittedCount = 0; + for (int index = 0; index < count; index++) + { + if (!TryGetItem(annotations, index, logger, out AIAnnotation? annotation) || + annotation is null) + { + omittedCount++; + continue; + } + + projection.Add((JsonNode)CreateAnnotationProjection(annotation, logger)); + } + + if (projection.Count > 0) + { + envelope[AnnotationsPropertyName] = projection; + } + + if (omittedCount > 0) + { + omissions[AnnotationsPropertyName] = omittedCount; + } + } + + private static JsonObject CreateAnnotationProjection( + AIAnnotation annotation, + ILogger? logger) + { + JsonObject projection = []; + JsonObject omissions = []; + + AddAdditionalProperties( + annotation.AdditionalProperties, + projection, + omissions, + logger); + AddAnnotatedRegions( + annotation.AnnotatedRegions, + projection, + omissions, + logger); + AddRawRepresentation( + annotation.RawRepresentation, + projection, + omissions, + logger); + + if (omissions.Count > 0) + { + projection[OmittedPropertyName] = omissions; + } + + return projection; + } + + private static void AddAdditionalProperties( + AdditionalPropertiesDictionary? additionalProperties, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (additionalProperties is null) + { + return; + } + + KeyValuePair[] entries; + try + { + entries = [.. additionalProperties]; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, additionalProperties, exception); + omissions[AdditionalPropertiesPropertyName] = true; + return; + } + + JsonObject projectedProperties = []; + int omittedCount = 0; + foreach ((string key, object? value) in entries) + { + if (TryConvertToJsonNode(value, logger, out JsonNode? jsonValue)) + { + projectedProperties[key] = jsonValue; + } + else + { + omittedCount++; + } + } + + if (projectedProperties.Count > 0) + { + projection[AdditionalPropertiesPropertyName] = projectedProperties; + } + + if (omittedCount > 0) + { + omissions[AdditionalPropertiesPropertyName] = omittedCount; + } + } + + private static void AddAnnotatedRegions( + IList? annotatedRegions, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (annotatedRegions is null) + { + return; + } + + if (!TryGetCount(annotatedRegions, logger, out int count)) + { + omissions[AnnotatedRegionsPropertyName] = true; + return; + } + + JsonArray projectedRegions = []; + int omittedCount = 0; + for (int index = 0; index < count; index++) + { + if (!TryGetItem(annotatedRegions, index, logger, out AnnotatedRegion? region) || + region is null || + !TryConvertToJsonNode(region, logger, out JsonNode? jsonValue)) + { + omittedCount++; + continue; + } + + projectedRegions.Add(jsonValue); + } + + if (projectedRegions.Count > 0) + { + projection[AnnotatedRegionsPropertyName] = projectedRegions; + } + + if (omittedCount > 0) + { + omissions[AnnotatedRegionsPropertyName] = omittedCount; + } + } + + private static void AddRawRepresentation( + object? rawRepresentation, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (rawRepresentation is null) + { + return; + } + + if (TryConvertToJsonNode(rawRepresentation, logger, out JsonNode? jsonValue)) + { + projection[RawRepresentationPropertyName] = jsonValue; + } + else + { + omissions[RawRepresentationPropertyName] = true; + } + } + + private static bool TryConvertToJsonNode( + object? value, + ILogger? logger, + out JsonNode? jsonValue) + { + try + { + JsonElement element = ToJsonElement(value).Clone(); + jsonValue = ToJsonNode(element); + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, value, exception); + jsonValue = null; + return false; + } + } + + private static bool TryGetCount( + IList values, + ILogger? logger, + out int count) + { + try + { + count = values.Count; + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, values, exception); + count = 0; + return false; + } + } + + private static bool TryGetItem( + IList values, + int index, + ILogger? logger, + out T? value) + { + try + { + value = values[index]; + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, values, exception); + value = default; + return false; + } + } + + private static JsonNode? ToJsonNode(JsonElement element) + { + return JsonNode.Parse(element.GetRawText()); + } + + private static bool TryGetEnvelope( + JsonElement content, + out JsonElement envelope, + out string? kind) + { + envelope = default; + kind = null; + if (content.ValueKind != JsonValueKind.Object || + !HasExactlyOneProperty(content, DurableEnvelopePropertyName) || + !content.TryGetProperty(DurableEnvelopePropertyName, out envelope) || + envelope.ValueKind != JsonValueKind.Object || + !envelope.TryGetProperty(KindPropertyName, out JsonElement kindElement) || + kindElement.ValueKind != JsonValueKind.String || + !envelope.TryGetProperty(VersionPropertyName, out JsonElement versionElement) || + versionElement.ValueKind != JsonValueKind.Number || + !versionElement.TryGetInt32(out int version) || + version != DurableEnvelopeVersion) + { + return false; + } + + kind = kindElement.GetString(); + return kind is not null; + } + + private static bool TryReadUnknownContent( + JsonElement envelope, + out AIContent content) + { + content = null!; + if (!HasOnlyProperties( + envelope, + KindPropertyName, + VersionPropertyName, + AnnotationsPropertyName, + AdditionalPropertiesPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAnnotations(envelope, out List? annotations) || + !TryReadAdditionalProperties( + envelope, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(envelope, out object? rawRepresentation) || + !HasValidOmissions(envelope)) + { + return false; + } + + content = new AIContent + { + Annotations = annotations, + AdditionalProperties = additionalProperties, + RawRepresentation = rawRepresentation, + }; + return true; + } + + private static bool TryReadAnnotations( + JsonElement envelope, + out List? annotations) + { + annotations = null; + if (!envelope.TryGetProperty(AnnotationsPropertyName, out JsonElement annotationsElement)) + { + return true; + } + + if (annotationsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + List result = []; + JsonTypeInfo regionTypeInfo = + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AnnotatedRegion)); + foreach (JsonElement annotationElement in annotationsElement.EnumerateArray()) + { + if (annotationElement.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + annotationElement, + AdditionalPropertiesPropertyName, + AnnotatedRegionsPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAdditionalProperties( + annotationElement, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(annotationElement, out object? rawRepresentation) || + !HasValidOmissions(annotationElement)) + { + return false; + } + + List? regions = null; + if (annotationElement.TryGetProperty( + AnnotatedRegionsPropertyName, + out JsonElement regionsElement)) + { + if (regionsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + regions = []; + foreach (JsonElement regionElement in regionsElement.EnumerateArray()) + { + try + { + if (regionElement.Deserialize(regionTypeInfo) is not AnnotatedRegion region) + { + return false; + } + + regions.Add(region); + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + return false; + } + } + } + + result.Add(new AIAnnotation + { + AdditionalProperties = additionalProperties, + AnnotatedRegions = regions, + RawRepresentation = rawRepresentation, + }); + } + + annotations = result; + return true; + } + + private static bool TryReadAdditionalProperties( + JsonElement envelope, + out AdditionalPropertiesDictionary? additionalProperties) + { + additionalProperties = null; + if (!envelope.TryGetProperty( + AdditionalPropertiesPropertyName, + out JsonElement additionalPropertiesElement)) + { + return true; + } + + if (additionalPropertiesElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + AdditionalPropertiesDictionary result = []; + foreach (JsonProperty property in additionalPropertiesElement.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + additionalProperties = result; + return true; + } + + private static bool TryReadRawRepresentation( + JsonElement envelope, + out object? rawRepresentation) + { + rawRepresentation = null; + if (envelope.TryGetProperty( + RawRepresentationPropertyName, + out JsonElement rawRepresentationElement)) + { + rawRepresentation = rawRepresentationElement.Clone(); + } + + return true; + } + + private static bool HasValidOmissions(JsonElement envelope) + { + if (!envelope.TryGetProperty(OmittedPropertyName, out JsonElement omittedElement)) + { + return true; + } + + if (omittedElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (JsonProperty property in omittedElement.EnumerateObject()) + { + if (property.Name is not ( + AnnotationsPropertyName or + AdditionalPropertiesPropertyName or + RawRepresentationPropertyName or + AnnotatedRegionsPropertyName) || + (property.Value.ValueKind != JsonValueKind.True && + property.Value.ValueKind != JsonValueKind.False && + (property.Value.ValueKind != JsonValueKind.Number || + !property.Value.TryGetInt32(out int count) || + count < 0))) + { + return false; + } + } + + return true; + } + + private static bool HasExactlyOneProperty(JsonElement element, string propertyName) + { + int count = 0; + foreach (JsonProperty property in element.EnumerateObject()) + { + count++; + if (!property.NameEquals(propertyName) || count > 1) + { + return false; + } + } + + return count == 1; + } + + private static bool HasOnlyProperties(JsonElement element, params string[] allowedNames) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!allowedNames.Contains(property.Name, StringComparer.Ordinal)) + { + return false; + } + } + + return true; + } + + private static AIContent CreateOpaqueAIContent(JsonElement content) + { + return new AIContent + { + RawRepresentation = content.Clone(), + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["content"] = content.Clone(), + }, + }; + } + + private static void LogSerializationFallback( + ILogger? logger, + object? value, + Exception exception) + { + if (logger is null) + { + return; + } + + try + { + logger.LogUnknownContentSerializationFallback( + value?.GetType().FullName ?? "null", + GetFailureCategory(exception)); + } + catch (Exception loggingException) when (IsRecoverableSerializationFailure(loggingException)) + { + } + } + + private static string GetFailureCategory(Exception exception) + { + return exception switch + { + ObjectDisposedException => "disposedValue", + JsonException => "invalidJson", + NotSupportedException => "unsupportedType", + InvalidOperationException => "invalidOperation", + ArgumentException => "invalidValue", + FormatException => "invalidFormat", + OverflowException => "numericOverflow", + IOException => "ioFailure", + _ => "customSerializationFailure", + }; + } + + private static bool IsRecoverableSerializationFailure(Exception exception) + { + if (exception is OperationCanceledException or + OutOfMemoryException or + StackOverflowException or + AccessViolationException or + AppDomainUnloadedException or + BadImageFormatException or + CannotUnloadAppDomainException or + InvalidProgramException or + SEHException) + { + return false; + } + + if (exception is AggregateException aggregateException) + { + return aggregateException.InnerExceptions.All(IsRecoverableSerializationFailure); + } - return content ?? throw new InvalidOperationException($"The content '{this.Content}' is not valid AI content."); + return exception.InnerException is null || + IsRecoverableSerializationFailure(exception.InnerException); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs index 1b3714f..d5b6e6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs @@ -34,10 +34,17 @@ internal sealed class DurableAgentStateUsage public long? TotalTokenCount { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets provider-specific usage counts from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown usage properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . @@ -51,7 +58,12 @@ usage is not null { InputTokenCount = usage.InputTokenCount, OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount + TotalTokenCount = usage.TotalTokenCount, + ExtensionData = usage.AdditionalCounts?.ToDictionary( + pair => pair.Key, + pair => JsonSerializer.SerializeToElement( + pair.Value, + DurableAgentStateJsonContext.Default.Int64)), } : null; @@ -61,11 +73,31 @@ usage is not null /// A representing this usage. public UsageDetails ToUsageDetails() { + AdditionalPropertiesDictionary? additionalCounts = null; + foreach (IDictionary? values in new[] { this.ExtensionData, this.UnknownProperties }) + { + if (values is null) + { + continue; + } + + foreach ((string name, JsonElement value) in values) + { + if (value.ValueKind == JsonValueKind.Number && + value.TryGetInt64(out long count)) + { + additionalCounts ??= []; + additionalCounts[name] = count; + } + } + } + return new() { InputTokenCount = this.InputTokenCount, OutputTokenCount = this.OutputTokenCount, - TotalTokenCount = this.TotalTokenCount + TotalTokenCount = this.TotalTokenCount, + AdditionalCounts = additionalCounts, }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 58166f0..46d82bf 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -1,12 +1,13 @@ # Durable Agent State -Durable agents are represented as durable entities, with each session of conversation history stored as JSON-serialized state for an individual entity instance. +Durable agents are represented as durable entities, with conversation history stored as JSON-serialized +state for an individual entity instance. ## State Schema The [schema](../../../../schemas/durable-agent-entity-state.json) for durable agent state is a distillation of the prompt and response messages accumulated over the lifetime of a session. While these messages and content originate from Microsoft Agent Framework types (for .NET, see [ChatMessage](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs) and [AIContent](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs)), durable agent state uses its own, parallel, types in order to (1) better manage the versioning and compatibility of serialized state over time, (2) account for agent implementations across languages/platforms (e.g. .NET and Python), as well as (3) ensure consistency for external tools that make use of state data. -> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type can be used when an AI content type is encountered but no equivalent type exists. +> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type is used when an AI content type is encountered but no equivalent type exists. Arbitrary `unknown.content` JSON is opaque: generic producer fields, including `$runtimeType`, are never interpreted by .NET and round-trip unchanged. .NET uses the single namespaced `$microsoftAgentFrameworkDurableTask` property only for its versioned metadata envelope. That envelope contains no runtime type name and can restore only the common `AIContent` contract (`Annotations`, `AdditionalProperties`, and safely serializable `RawRepresentation`); it can never select or construct a CLR type. Common metadata values are converted independently. Unsupported, cyclic, disposed, invalid, or getter/converter-failing values are omitted while safe siblings remain, an omission count/flag is recorded, and a warning logs only the value's type and a fixed failure category. The final durable state therefore contains only JSON-safe data. ## State Versioning @@ -14,16 +15,41 @@ The serialized state contains a root `schemaVersion` property, which represents Some versioning considerations: -- Versions should use semver notation (e.g. `".."`) +- Versions use the strict numeric SemVer core grammar `".."`: exactly three + non-negative decimal components, with no leading zeroes except the single digit `0`. Prerelease + suffixes, build metadata, a `v` prefix, missing/extra components, and whitespace are rejected. - Durable agents should use the version property to determine how to deserialize that state and should not attempt to deserialize semver-incompatible versions - Newer versions of durable agents should strive to be compatible with older schema versions (e.g. new properties and objects should be optional) - Durable agents should preserve existing, but unrecognized, properties when serializing state +Schema version 1.2 adds optional message identity and extension metadata, opaque session state, workflow +`ingestedPositions`, and bounded truncation evidence. The .NET workflow path preserves but does not currently +populate `ingestedPositions`. Older 1.x state remains readable. `DurableAgentState.Clone()` promotes older +supported versions to 1.2 when a caller uses that write-clone path, while later same-major versions remain +unchanged. Wiring that path into entity execution is deferred. Major versions remain fail-closed. New +`DurableAgentState` instances default to the current version, while deserialization preserves the persisted +version through an init-only property. + +The schema's declared `extensionData` objects and forward-compatible unknown JSON properties are distinct. +The .NET model names declared metadata `ExtensionData` (or message `AdditionalProperties`) and names +`[JsonExtensionData]` catch-all dictionaries `UnknownProperties`. Both coexist and round-trip independently +at root, data, entry, message, and usage boundaries. Content types also use `UnknownProperties`; the current +schema does not declare a content-level `extensionData` field. Unknown fields are never folded into an +application-defined `extensionData` object. + +Usage `extensionData` is preserved as arbitrary JSON for forward compatibility. When a durable response is +projected to `UsageDetails`, only integral numeric extension values representable as `Int64` become additional +counts; strings, objects, arrays, fractional numbers, and out-of-range numbers remain in durable state but are +ignored by the runtime projection. Malformed known count fields fail deserialization rather than being silently reinterpreted. + +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. + ## Sample State ```json { - "schemaVersion": "1.0.0", + "schemaVersion": "1.2.0", "data": { "conversationHistory": [ { diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index c548159..d3c993b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -10,4 +10,10 @@ + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs index 2fda117..6e1badc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.Agents.AI.DurableTask.State; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; @@ -42,6 +44,29 @@ public void ErrorContentSerializationDeserialization() Assert.Equal(errorContent.ErrorCode, convertedErrorContent.ErrorCode); } + [Fact] + public void ErrorContentPreservesNonStringPythonDetails() + { + const string Json = """ + { + "$type": "error", + "message": "failed", + "details": { + "retryable": true + } + } + """; + DurableAgentStateContent stored = Assert.IsType( + JsonSerializer.Deserialize(Json, s_stateContentTypeInfo)); + + ErrorContent restored = Assert.IsType(stored.ToAIContent()); + string roundTrip = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + + using JsonDocument details = JsonDocument.Parse(restored.Details!); + Assert.True(details.RootElement.GetProperty("retryable").GetBoolean()); + Assert.Contains("\"details\":{\"retryable\":true}", roundTrip, StringComparison.Ordinal); + } + [Fact] public void TextContentSerializationDeserialization() { @@ -299,26 +324,464 @@ public void UsageContentSerializationDeserialization() } [Fact] - public void UnknownContentSerializationDeserialization() + public void UsageAdditionalCountsRoundTripThroughExtensionData() { - // Arrange - TextContent originalContent = new("Some unknown content"); + UsageDetails usageDetails = new() + { + InputTokenCount = 10, + AdditionalCounts = new AdditionalPropertiesDictionary + { + ["providerCount"] = 7, + }, + }; - DurableAgentStateContent durableContent = DurableAgentStateUnknownContent.FromUnknownContent(originalContent); + DurableAgentStateUsage stored = Assert.IsType( + DurableAgentStateUsage.FromUsage(usageDetails)); + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!); + DurableAgentStateUsage restored = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!)); + UsageDetails converted = restored.ToUsageDetails(); + + Assert.Contains("\"extensionData\":{\"providerCount\":7}", json, StringComparison.Ordinal); + Assert.Equal(7, converted.AdditionalCounts?["providerCount"]); + } - // Act - string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + [Fact] + public void UsageProjectionIgnoresMalformedExtensionsAndPreservesTheirJson() + { + const string Json = """ + { + "inputTokenCount": 10, + "extensionData": { + "providerCount": 7, + "futureString": "seven", + "futureObject": { "count": 8 }, + "futureArray": [9], + "fractional": 1.5, + "tooLarge": 9223372036854775808 + }, + "futureTopLevelCount": 11, + "futureTopLevelObject": { "count": 12 } + } + """; + JsonTypeInfo usageTypeInfo = + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!; + DurableAgentStateUsage stored = Assert.IsType( + JsonSerializer.Deserialize(Json, usageTypeInfo)); + + UsageDetails usage = stored.ToUsageDetails(); + string roundTrip = JsonSerializer.Serialize(stored, usageTypeInfo); + + Assert.Equal(10, usage.InputTokenCount); + Assert.Equal(7, usage.AdditionalCounts?["providerCount"]); + Assert.Equal(11, usage.AdditionalCounts?["futureTopLevelCount"]); + Assert.DoesNotContain("futureString", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureObject", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureArray", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("fractional", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("tooLarge", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureTopLevelObject", usage.AdditionalCounts?.Keys ?? []); + Assert.Contains("\"futureString\":\"seven\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureObject\":{\"count\":8}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureArray\":[9]", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureTopLevelObject\":{\"count\":12}", roundTrip, StringComparison.Ordinal); + } - DurableAgentStateContent? convertedJsonContent = - (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + [Theory] + [InlineData("\"ten\"")] + [InlineData("{}")] + [InlineData("1.5")] + [InlineData("9223372036854775808")] + public void UsageDeserializationRejectsMalformedKnownNumericFields(string invalidValue) + { + string json = $$""" + { + "inputTokenCount": {{invalidValue}} + } + """; + JsonTypeInfo usageTypeInfo = + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!; - // Assert - Assert.NotNull(convertedJsonContent); + Assert.Throws(() => JsonSerializer.Deserialize(json, usageTypeInfo)); + } - AIContent convertedContent = convertedJsonContent.ToAIContent(); + [Fact] + public void KnownContentDiscriminatorDoesNotUseUnknownEnvelope() + { + TextContent originalContent = new("Some unknown content"); + DurableAgentStateContent durableContent = + DurableAgentStateContent.FromAIContent(originalContent); + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + DurableAgentStateTextContent convertedState = + Assert.IsType(convertedJsonContent); + AIContent convertedContent = convertedState.ToAIContent(); TextContent convertedTextContent = Assert.IsType(convertedContent); Assert.Equal(originalContent.Text, convertedTextContent.Text); + Assert.Contains("\"$type\":\"text\"", jsonContent, StringComparison.Ordinal); + Assert.DoesNotContain("$microsoftAgentFrameworkDurableTask", jsonContent, StringComparison.Ordinal); + Assert.DoesNotContain("$runtimeType", jsonContent, StringComparison.Ordinal); + } + + [Fact] + public void UnknownContentWithUnrecognizedPayloadFallsBackWithoutDataLoss() + { + DurableAgentStateUnknownContent stored = new() + { + Content = JsonSerializer.SerializeToElement( + new { type = "future_content", value = 42 }), + }; + + AIContent restored = stored.ToAIContent(); + + JsonElement content = + Assert.IsType(restored.AdditionalProperties?["content"]); + Assert.Equal("future_content", content.GetProperty("type").GetString()); + Assert.Equal(42, content.GetProperty("value").GetInt32()); + } + + [Fact] + public void PythonShapedOpaqueUnknownContentWithRuntimeTypeRoundTripsUnchanged() + { + using JsonDocument document = JsonDocument.Parse( + """ + { + "$runtimeType": "producer-owned-user-value", + "type": "future_python_content", + "annotations": [{ "kind": "citation", "value": "python-ref" }], + "additionalProperties": { "producer": "python" }, + "future": { "nested": [1, 2, 3] } + } + """); + JsonElement original = document.RootElement.Clone(); + DurableAgentStateUnknownContent stored = new() { Content = original }; + + AIContent restored = Assert.IsType(stored.ToAIContent()); + DurableAgentStateUnknownContent roundTripped = Assert.IsType( + DurableAgentStateContent.FromAIContent(restored)); + + Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); + Assert.Equal( + "producer-owned-user-value", + roundTripped.Content.GetProperty("$runtimeType").GetString()); + Assert.Equal( + 3, + roundTripped.Content.GetProperty("future").GetProperty("nested").GetArrayLength()); + } + + [Fact] + public void FutureDurableEnvelopeFieldsRemainOpaque() + { + using JsonDocument document = JsonDocument.Parse( + """ + { + "$microsoftAgentFrameworkDurableTask": { + "kind": "unknownAIContent", + "version": 1, + "futureMetadata": { "preserve": true } + } + } + """); + JsonElement original = document.RootElement.Clone(); + DurableAgentStateUnknownContent stored = new() { Content = original }; + + AIContent restored = Assert.IsType(stored.ToAIContent()); + DurableAgentStateUnknownContent roundTripped = Assert.IsType( + DurableAgentStateContent.FromAIContent(restored)); + + Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); + } + + [Fact] + public void UnregisteredAIContentSubtypePersistsCommonContractAsUnknown() + { + FutureContent original = new() + { + FutureValue = "not part of the common contract", + RawRepresentation = new { kind = "future", value = 42 }, + AdditionalProperties = new() + { + ["providerFlag"] = true, + }, + Annotations = + [ + new AIAnnotation + { + AdditionalProperties = new() + { + ["citation"] = "ref-1", + }, + }, + ], + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + DurableAgentStateContent roundTripped = Assert.IsType( + JsonSerializer.Deserialize(json, s_stateContentTypeInfo)); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement persistedContent = document.RootElement.GetProperty("content"); + JsonElement envelope = + persistedContent.GetProperty("$microsoftAgentFrameworkDurableTask"); + Assert.Equal("unknownAIContent", envelope.GetProperty("kind").GetString()); + Assert.Equal(1, envelope.GetProperty("version").GetInt32()); + Assert.False(persistedContent.TryGetProperty("$runtimeType", out _)); + Assert.DoesNotContain(typeof(FutureContent).FullName!, json, StringComparison.Ordinal); + Assert.False(envelope.TryGetProperty(nameof(FutureContent.FutureValue), out _)); + + AIContent restored = Assert.IsType(roundTripped.ToAIContent()); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["providerFlag"]).GetBoolean()); + Assert.Equal( + "ref-1", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["citation"]).GetString()); + JsonElement rawRepresentation = Assert.IsType(restored.RawRepresentation); + Assert.Equal("future", rawRepresentation.GetProperty("kind").GetString()); + Assert.Equal(42, rawRepresentation.GetProperty("value").GetInt32()); + } + + [Fact] + public void UnknownContentOmitsUnsafeValuesAndPreservesSafeMetadata() + { + CyclicPayload cyclicPayload = new(); + cyclicPayload.Self = cyclicPayload; + JsonElement disposedElement; + using (JsonDocument disposedDocument = JsonDocument.Parse("""{"value":"disposed-secret"}""")) + { + disposedElement = disposedDocument.RootElement; + } + + using MemoryStream stream = new([1, 2, 3]); + CollectingLogger logger = new(); + FutureContent original = new() + { + RawRepresentation = new ThrowingGetterPayload(), + AdditionalProperties = new() + { + ["safeString"] = "kept", + ["safeObject"] = new { value = 42 }, + ["cyclic"] = cyclicPayload, + ["delegate"] = () => { }, + ["stream"] = stream, + ["disposedJson"] = disposedElement, + ["invalidNumber"] = double.NaN, + ["customConverter"] = new ThrowingConverterPayload(), + }, + Annotations = + [ + new AIAnnotation + { + RawRepresentation = disposedElement, + AdditionalProperties = new() + { + ["safeAnnotation"] = "annotation-kept", + ["badAnnotation"] = new ThrowingConverterPayload(), + }, + }, + ], + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original, logger)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + Messages = + [ + new DurableAgentStateMessage + { + Role = "assistant", + Contents = [stored], + }, + ], + }); + Exception? finalSerializationException = Record.Exception( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement envelope = document.RootElement.GetProperty("content") + .GetProperty("$microsoftAgentFrameworkDurableTask"); + JsonElement additionalProperties = envelope.GetProperty("additionalProperties"); + Assert.Equal("kept", additionalProperties.GetProperty("safeString").GetString()); + Assert.Equal(42, additionalProperties.GetProperty("safeObject").GetProperty("value").GetInt32()); + Assert.False(additionalProperties.TryGetProperty("cyclic", out _)); + Assert.False(additionalProperties.TryGetProperty("delegate", out _)); + Assert.False(additionalProperties.TryGetProperty("stream", out _)); + Assert.False(additionalProperties.TryGetProperty("disposedJson", out _)); + Assert.False(additionalProperties.TryGetProperty("invalidNumber", out _)); + Assert.False(additionalProperties.TryGetProperty("customConverter", out _)); + Assert.True(envelope.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + Assert.Equal(6, envelope.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + + JsonElement annotation = envelope.GetProperty("annotations")[0]; + Assert.Equal( + "annotation-kept", + annotation.GetProperty("additionalProperties").GetProperty("safeAnnotation").GetString()); + Assert.False( + annotation.GetProperty("additionalProperties").TryGetProperty("badAnnotation", out _)); + Assert.Equal( + 1, + annotation.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + Assert.True( + annotation.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + + AIContent restored = Assert.IsType(stored.ToAIContent()); + Assert.Equal( + "kept", + Assert.IsType(restored.AdditionalProperties?["safeString"]).GetString()); + Assert.Equal( + "annotation-kept", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["safeAnnotation"]).GetString()); + + Assert.Null(finalSerializationException); + Assert.True(logger.WarningCount >= 8); + Assert.All(logger.Exceptions, exception => Assert.Null(exception)); + Assert.All( + logger.Messages, + message => + { + Assert.DoesNotContain("disposed-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("converter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("safeString", message, StringComparison.Ordinal); + }); + } + + [Fact] + public void UnknownSubtypePropertyGetterIsNeverInvoked() + { + ThrowingFutureContent.GetterInvocationCount = 0; + ThrowingFutureContent original = new() + { + AdditionalProperties = new() + { + ["safe"] = true, + }, + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + AIContent restored = stored.ToAIContent(); + + Assert.Equal(0, ThrowingFutureContent.GetterInvocationCount); + Assert.IsType(restored); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["safe"]).GetBoolean()); + Assert.DoesNotContain("$runtimeType", json, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", json, StringComparison.Ordinal); + } + + [Fact] + public void UnknownContentDoesNotSwallowCancellation() + { + FutureContent original = new() + { + RawRepresentation = new CancelingGetterPayload(), + }; + + Assert.ThrowsAny( + () => DurableAgentStateContent.FromAIContent(original)); + } + + private sealed class FutureContent : AIContent + { + public string? FutureValue { get; init; } + } + + private sealed class CyclicPayload + { + public CyclicPayload? Self { get; set; } + } + + private sealed class ThrowingFutureContent : AIContent + { + public static int GetterInvocationCount { get; set; } + + public string Dangerous + { + get + { + GetterInvocationCount++; + throw new InvalidOperationException("getter-secret"); + } + } + } + + private sealed class ThrowingGetterPayload + { + public string Dangerous => throw new InvalidOperationException("getter-secret"); + } + + private sealed class CancelingGetterPayload + { + public string Dangerous => throw new OperationCanceledException(); + } + + [JsonConverter(typeof(ThrowingConverterPayloadConverter))] + public sealed class ThrowingConverterPayload; + + public sealed class ThrowingConverterPayloadConverter : JsonConverter + { + public override ThrowingConverterPayload? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write( + Utf8JsonWriter writer, + ThrowingConverterPayload value, + JsonSerializerOptions options) + { + throw new FormatException("converter-secret"); + } + } + + private sealed class CollectingLogger : ILogger + { + public int WarningCount { get; private set; } + + public List Messages { get; } = []; + + public List Exceptions { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + this.WarningCount++; + this.Messages.Add(formatter(state, exception)); + this.Exceptions.Add(exception); + } + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs index 343644d..acc7042 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs @@ -44,4 +44,216 @@ public void MessageSerializationDeserialization() Assert.Equal(textContent.Text, convertedTextContent.Text); } + + [Fact] + public void MessageIdAndAdditionalPropertiesRoundTrip() + { + ChatMessage message = new(ChatRole.User, "hello") + { + MessageId = "message-1", + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["excluded"] = true, + ["summary"] = "summary-1", + }, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + ChatMessage restored = stored.ToChatMessage(); + + Assert.Equal("message-1", restored.MessageId); + Assert.NotNull(restored.AdditionalProperties); + Assert.Equal(JsonValueKind.True, Assert.IsType(restored.AdditionalProperties["excluded"]).ValueKind); + Assert.Equal("summary-1", Assert.IsType(restored.AdditionalProperties["summary"]).GetString()); + } + + [Fact] + public void StandaloneConversionDoesNotInventRandomIdentity() + { + DurableAgentStateMessage stored = + DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.User, "hello")); + + Assert.Null(stored.MessageId); + Assert.Null(stored.ToChatMessage().MessageId); + } + + [Fact] + public void EntryFactoriesSynthesizeDeterministicMessageIds() + { + RunRequest request = new("hello") { CorrelationId = "correlation" }; + + DurableAgentStateRequest first = DurableAgentStateRequest.FromRunRequest(request); + DurableAgentStateRequest second = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("durable_request_correlation_0", first.Messages[0].MessageId); + Assert.Equal(first.Messages[0].MessageId, second.Messages[0].MessageId); + } + + [Fact] + public void EntryFactoriesPreserveProducerMessageIds() + { + RunRequest request = new( + [new ChatMessage(ChatRole.User, "hello") { MessageId = "producer-id" }]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("producer-id", stored.Messages[0].MessageId); + } + + [Fact] + public void RequestFactoryUsesStoredPositionsWithoutFiltering() + { + RunRequest request = new( + [ + new ChatMessage(ChatRole.User, [new AIContent()]), + new ChatMessage(ChatRole.User, "hello"), + ]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal( + ["durable_request_correlation_0", "durable_request_correlation_1"], + stored.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyCorrelationlessCompactionUsesStoredPositions() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + DurableAgentStateCompaction compaction = new() + { + CreatedAt = createdAt, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextContent { Text = "summary" }], + }, + ], + }; + + DurableAgentStateMessageIdentity.EnsureMessageIds([compaction]); + + Assert.Equal( + [ + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + ], + compaction.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyEmptyCorrelationIdUsesTimestampScope() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + + string messageId = DurableAgentStateMessageIdentity.Create( + "compaction", + string.Empty, + createdAt, + storedIndex: 1); + + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + messageId); + } + + [Fact] + public void AdditionalPropertiesAreCopiedOnBothConversions() + { + AdditionalPropertiesDictionary producerProperties = new() + { + ["marker"] = "original", + }; + ChatMessage message = new(ChatRole.User, "hello") + { + AdditionalProperties = producerProperties, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + producerProperties["marker"] = "producer-mutated"; + ChatMessage restored = stored.ToChatMessage(); + restored.AdditionalProperties!["marker"] = "consumer-mutated"; + + Assert.Equal("original", stored.AdditionalProperties?["marker"].GetString()); + } + + [Theory] + [InlineData("2026-07-27T12:34:56+00:00", "2026-07-27T12:34:56+00:00")] + [InlineData("2026-07-27T12:34:56.1234567+00:00", "2026-07-27T12:34:56.123456+00:00")] + [InlineData("2026-07-27T12:34:56.1000000+05:30", "2026-07-27T12:34:56.100000+05:30")] + public void CorrelationlessTimestampScopeMatchesPythonIsoFormat(string input, string expected) + { + DateTimeOffset timestamp = DateTimeOffset.Parse(input); + + string actual = DurableAgentStateMessageIdentity.FormatPythonIsoTimestamp(timestamp); + + Assert.Equal(expected, actual); + } + + [Fact] + public void VersionOnePointTwoFixtureRoundTrips() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "correlationId": "request-1", + "createdAt": "2026-01-01T00:00:00Z", + "messages": [{ + "role": "user", + "messageId": "message-1", + "extensionData": { "excluded": true }, + "contents": [{ "$type": "text", "text": "hello" }] + }] + }], + "session": { + "conversationId": "service-1", + "stateBag": {} + }, + "ingestedPositions": { + "input": 0, + "writer": 1 + }, + "truncation": { + "evictedMessageCount": 2, + "firstEvictedAt": "2026-01-01T00:00:00Z", + "lastEvictedAt": "2026-01-02T00:00:00Z" + } + } + } + """; + + DurableAgentState? state = JsonSerializer.Deserialize( + Json, + DurableAgentStateJsonContext.Default.DurableAgentState); + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.NotNull(state); + Assert.Equal("1.2.0", state.SchemaVersion); + Assert.Equal("message-1", state.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal(0, state.Data.IngestedPositions?["input"]); + Assert.Equal(1, state.Data.IngestedPositions?["writer"]); + Assert.Contains("\"conversationId\":\"service-1\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"ingestedPositions\":{\"input\":0,\"writer\":1}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"evictedMessageCount\":2", roundTrip, StringComparison.Ordinal); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs index a974f9d..66caf9f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateResponseTests { [Fact] - public void FromResponseDropsMessagesContainingOnlyOpaqueContent() + public void FromResponsePreservesMessagesContainingOnlyOpaqueContent() { // Arrange: one message with real text, one with only opaque AIContent ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!") @@ -32,15 +32,14 @@ public void FromResponseDropsMessagesContainingOnlyOpaqueContent() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response); - // Assert: only the useful message survives - DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); - Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); + Assert.Equal(2, durableResponse.Messages.Count); + Assert.Equal(ChatRole.Assistant.Value, durableResponse.Messages[1].Role); - // Round-trip to verify the content is correct AgentResponse convertedResponse = durableResponse.ToResponse(); - ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages); - TextContent textContent = Assert.IsType(Assert.Single(convertedMessage.Contents)); + Assert.Equal(2, convertedResponse.Messages.Count); + TextContent textContent = Assert.IsType(Assert.Single(convertedResponse.Messages[0].Contents)); Assert.Equal("Hello, world!", textContent.Text); + Assert.IsType(Assert.Single(convertedResponse.Messages[1].Contents)); } [Fact] @@ -68,7 +67,7 @@ public void FromResponseKeepsMessagesWithMixedContent() } [Fact] - public void FromResponseDropsAllMessagesWhenAllAreOpaque() + public void FromResponsePreservesAllMessagesWhenAllAreOpaque() { // Arrange: all messages contain only opaque AIContent ChatMessage opaque1 = new(ChatRole.Assistant, [ @@ -90,8 +89,7 @@ public void FromResponseDropsAllMessagesWhenAllAreOpaque() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response); - // Assert: no messages stored - Assert.Empty(durableResponse.Messages); + Assert.Equal(2, durableResponse.Messages.Count); } [Fact] @@ -139,4 +137,133 @@ public void FromResponseKeepsBaseAIContentWithAdditionalProperties() // Assert: message is kept because the AIContent has additional properties Assert.Single(durableResponse.Messages); } + + [Fact] + public void FromResponseUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AdditionalProperties = new() { ["kind"] = "metadata" }, + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + AgentResponse response = new([metadataOnly, text]); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", response); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("durable_response_correlation_0", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromMessagesUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + MessageId = "producer-metadata-id", + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromMessages("correlation", [metadataOnly, text]); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("producer-metadata-id", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromResponsePreservesProducerIdAfterFiltering() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []); + ChatMessage text = new(ChatRole.Assistant, "kept") + { + MessageId = "producer-id", + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly, text])); + + Assert.Equal("producer-id", stored.Messages[1].MessageId); + } + + [Fact] + public void MetadataOnlyResponsePersistsAndRoundTrips() + { + DateTimeOffset createdAt = DateTimeOffset.Parse("2026-09-06T12:34:56+00:00"); + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AuthorName = "agent", + CreatedAt = createdAt, + MessageId = "producer-message-id", + AdditionalProperties = new() + { + ["trace"] = "value", + }, + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly])); + string json = System.Text.Json.JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse); + DurableAgentStateResponse restored = Assert.IsType( + System.Text.Json.JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse)); + ChatMessage roundTripped = Assert.Single(restored.ToResponse().Messages); + + Assert.Empty(roundTripped.Contents); + Assert.Equal(ChatRole.Assistant, roundTripped.Role); + Assert.Equal("agent", roundTripped.AuthorName); + Assert.Equal(createdAt, roundTripped.CreatedAt); + Assert.Equal("producer-message-id", roundTripped.MessageId); + Assert.Equal( + "value", + Assert.IsType( + roundTripped.AdditionalProperties?["trace"]).GetString()); + } + + [Fact] + public void ToResponseRetainsMetadataOnlyMessageForPolling() + { + DurableAgentStateResponse stored = new() + { + CorrelationId = "correlation", + CreatedAt = DateTimeOffset.Parse("2026-09-06T12:00:00+00:00"), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + MessageId = "pollable-metadata", + AdditionalProperties = new Dictionary + { + ["status"] = System.Text.Json.JsonSerializer.SerializeToElement("complete"), + }, + Contents = [], + }, + ], + }; + + ChatMessage message = Assert.Single(stored.ToResponse().Messages); + + Assert.Equal("pollable-metadata", message.MessageId); + Assert.Empty(message.Contents); + Assert.Equal( + "complete", + Assert.IsType( + message.AdditionalProperties?["status"]).GetString()); + } + + [Fact] + public void EmptyResponseGetsCreatedAtWithoutThrowing() + { + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse()); + + Assert.Empty(stored.Messages); + Assert.NotEqual(default, stored.CreatedAt); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs index f8ce5c6..e829ca6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -2,11 +2,20 @@ using System.Text.Json; using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateTests { + [Fact] + public void NewStateDefaultsToCurrentSchemaVersion() + { + DurableAgentState state = new(); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, state.SchemaVersion); + } + [Fact] public void InvalidVersion() { @@ -22,6 +31,96 @@ public void InvalidVersion() () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); } + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.9")] + [InlineData("1.2.0")] + [InlineData("1.2.7")] + [InlineData("1.3.0")] + [InlineData("1.9.2")] + [InlineData("1.2147483648.0")] + [InlineData("1.2.2147483648")] + public void StrictNumericSemVerIsAccepted(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + Assert.Equal(version, state.SchemaVersion); + } + + [Theory] + [InlineData("1.2")] + [InlineData("1.2.0.0")] + [InlineData("v1.2.0")] + [InlineData("")] + [InlineData(" ")] + [InlineData("-1.2.0")] + [InlineData("1.-2.0")] + [InlineData("1.2.-3")] + [InlineData("01.2.0")] + [InlineData("1.02.0")] + [InlineData("1.2.00")] + [InlineData("1.2.0-alpha")] + [InlineData("1.2.0+build")] + [InlineData("1.2.0-alpha+build")] + public void InvalidSchemaVersionGrammarIsRejected(string version) + { + string json = $$""" + { + "schemaVersion": {{JsonSerializer.Serialize(version)}}, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void NonStringSchemaVersionIsRejected() + { + const string JsonText = """ + { + "schemaVersion": 10200, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + JsonText, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void InvalidSchemaVersionCannotBeSerialized() + { + DurableAgentState state = new() + { + SchemaVersion = "1.2", + }; + + Assert.Throws( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + [Fact] public void BreakingVersion() { @@ -53,7 +152,7 @@ public void MissingData() } [Fact] - public void ExtraData() + public void UnknownDataPropertiesRoundTrip() { // Arrange const string JsonText = """ @@ -70,10 +169,10 @@ public void ExtraData() DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState); // Assert - Assert.NotNull(state?.Data?.ExtensionData); + Assert.NotNull(state?.Data?.UnknownProperties); - Assert.True(state.Data.ExtensionData!.ContainsKey("extraField")); - Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString()); + Assert.True(state.Data.UnknownProperties!.ContainsKey("extraField")); + Assert.Equal("someValue", state.Data.UnknownProperties["extraField"].ToString()); // Act string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); @@ -86,6 +185,89 @@ public void ExtraData() Assert.Equal("someValue", extraFieldElement.ToString()); } + [Fact] + public void DeclaredExtensionDataAndUnknownPropertiesRoundTripIndependently() + { + const string JsonText = """ + { + "schemaVersion": "1.2.0", + "extensionData": { "rootMetadata": "root" }, + "futureRoot": 1, + "data": { + "extensionData": { "dataMetadata": "data" }, + "futureData": 2, + "conversationHistory": [{ + "$type": "response", + "correlationId": "correlation", + "createdAt": "2026-09-07T12:00:00+00:00", + "extensionData": { "entryMetadata": "entry" }, + "futureEntry": 3, + "usage": { + "extensionData": { "providerCount": 4 }, + "futureUsage": 5 + }, + "messages": [{ + "role": "assistant", + "extensionData": { "messageMetadata": "message" }, + "futureMessage": 6, + "contents": [{ + "$type": "text", + "text": "answer", + "futureContent": 7 + }] + }] + }] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + DurableAgentStateResponse response = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + DurableAgentStateMessage message = Assert.Single(response.Messages); + DurableAgentStateTextContent content = + Assert.IsType(Assert.Single(message.Contents)); + DurableAgentStateUsage usage = Assert.IsType(response.Usage); + + Assert.Equal("root", state.ExtensionData?["rootMetadata"].GetString()); + Assert.Equal(1, state.UnknownProperties?["futureRoot"].GetInt32()); + Assert.Equal("data", state.Data.ExtensionData?["dataMetadata"].GetString()); + Assert.Equal(2, state.Data.UnknownProperties?["futureData"].GetInt32()); + Assert.Equal("entry", response.ExtensionData?["entryMetadata"].GetString()); + Assert.Equal(3, response.UnknownProperties?["futureEntry"].GetInt32()); + Assert.Equal("message", message.AdditionalProperties?["messageMetadata"].GetString()); + Assert.Equal(6, message.UnknownProperties?["futureMessage"].GetInt32()); + Assert.Equal(7, content.UnknownProperties?["futureContent"].GetInt32()); + Assert.Equal(4, usage.ExtensionData?["providerCount"].GetInt32()); + Assert.Equal(5, usage.UnknownProperties?["futureUsage"].GetInt32()); + + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement root = document.RootElement; + JsonElement data = root.GetProperty("data"); + JsonElement entry = data.GetProperty("conversationHistory")[0]; + JsonElement roundTrippedMessage = entry.GetProperty("messages")[0]; + JsonElement roundTrippedContent = roundTrippedMessage.GetProperty("contents")[0]; + JsonElement roundTrippedUsage = entry.GetProperty("usage"); + + Assert.Equal("root", root.GetProperty("extensionData").GetProperty("rootMetadata").GetString()); + Assert.Equal(1, root.GetProperty("futureRoot").GetInt32()); + Assert.Equal("data", data.GetProperty("extensionData").GetProperty("dataMetadata").GetString()); + Assert.Equal(2, data.GetProperty("futureData").GetInt32()); + Assert.Equal("entry", entry.GetProperty("extensionData").GetProperty("entryMetadata").GetString()); + Assert.Equal(3, entry.GetProperty("futureEntry").GetInt32()); + Assert.Equal( + "message", + roundTrippedMessage.GetProperty("extensionData").GetProperty("messageMetadata").GetString()); + Assert.Equal(6, roundTrippedMessage.GetProperty("futureMessage").GetInt32()); + Assert.Equal(7, roundTrippedContent.GetProperty("futureContent").GetInt32()); + Assert.Equal(4, roundTrippedUsage.GetProperty("extensionData").GetProperty("providerCount").GetInt32()); + Assert.Equal(5, roundTrippedUsage.GetProperty("futureUsage").GetInt32()); + } + [Fact] public void BasicState() { @@ -167,4 +349,206 @@ public void BasicState() Assert.Equal("Hi user!", textContent.Text); }); } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.0.7")] + [InlineData("1.1.0")] + [InlineData("1.1.9")] + public void CloneForWritePromotesOlderCompatibleStateToCurrentVersion(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [], + "ingestedPositions": { "writer": 2 } + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState promoted = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + promoted, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, promoted.SchemaVersion); + Assert.Equal(2, promoted.Data.IngestedPositions?["writer"]); + Assert.Contains("\"schemaVersion\":\"1.2.0\"", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("1.2.0")] + [InlineData("1.2.7")] + [InlineData("1.3.0")] + [InlineData("1.9.2")] + public void CloneForWritePreservesCurrentAndFutureCompatibleVersions(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [] + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState clone = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + clone, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(version, clone.SchemaVersion); + Assert.Contains($"\"schemaVersion\":\"{version}\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void FutureCompatibleVersionAndUnknownFieldsSurviveMutationAndRoundTrip() + { + const string JsonText = """ + { + "schemaVersion": "1.3.0", + "data": { + "conversationHistory": [ + { + "$type": "response", + "correlationId": "future", + "createdAt": "2026-09-06T12:00:00+00:00", + "futureEntry": { "keep": true }, + "messages": [ + { + "role": "assistant", + "contents": [], + "futureMessage": [1, 2, 3] + } + ] + } + ], + "futureData": "preserve" + }, + "futureRoot": 42 + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState mutated = state.Clone(); + mutated.Data.IngestedPositions = new Dictionary { ["writer"] = 7 }; + string roundTrip = JsonSerializer.Serialize( + mutated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("1.3.0", document.RootElement.GetProperty("schemaVersion").GetString()); + Assert.Equal(42, document.RootElement.GetProperty("futureRoot").GetInt32()); + JsonElement data = document.RootElement.GetProperty("data"); + Assert.Equal("preserve", data.GetProperty("futureData").GetString()); + Assert.Equal(7, data.GetProperty("ingestedPositions").GetProperty("writer").GetInt32()); + JsonElement response = data.GetProperty("conversationHistory")[0]; + Assert.True(response.GetProperty("futureEntry").GetProperty("keep").GetBoolean()); + Assert.Equal(3, response.GetProperty("messages")[0].GetProperty("futureMessage").GetArrayLength()); + } + + [Fact] + public void PythonPr59ShapeFixtureMigratesIdsAndPreservesExtensions() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "python-durable-agent-state-1.2.json")); + using JsonDocument sourceDocument = JsonDocument.Parse(json); + JsonElement sourceUnknownContent = sourceDocument.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState migrated = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + migrated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("producer-request-id", migrated.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal("python-metadata-only", migrated.Data.ConversationHistory[1].Messages[0].MessageId); + Assert.Equal("durable_response_corr-python_1", migrated.Data.ConversationHistory[1].Messages[1].MessageId); + Assert.Equal("durable_response_corr-python_2", migrated.Data.ConversationHistory[1].Messages[2].MessageId); + Assert.Equal("python-unknown-content", migrated.Data.ConversationHistory[1].Messages[3].MessageId); + Assert.Equal("durable_errorResponse_corr-error_0", migrated.Data.ConversationHistory[2].Messages[0].MessageId); + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + migrated.Data.ConversationHistory[3].Messages[0].MessageId); + DurableAgentStateResponse response = + Assert.IsType(migrated.Data.ConversationHistory[1]); + ChatMessage metadataOnly = response.ToResponse().Messages[0]; + Assert.Empty(metadataOnly.Contents); + Assert.Equal("python-metadata-only", metadataOnly.MessageId); + Assert.Equal("python-agent", metadataOnly.AuthorName); + Assert.Equal( + DateTimeOffset.Parse("2026-07-27T12:34:51+00:00"), + metadataOnly.CreatedAt); + Assert.Equal( + "python", + Assert.IsType(metadataOnly.AdditionalProperties?["metadataOrigin"]).GetString()); + DurableAgentStateUnknownContent unknown = Assert.IsType( + migrated.Data.ConversationHistory[1].Messages[3].Contents[0]); + JsonElement pythonContent = unknown.Content; + Assert.Equal( + "python-owned-user-field", + pythonContent.GetProperty("$runtimeType").GetString()); + Assert.Equal("future_python_content", pythonContent.GetProperty("type").GetString()); + Assert.Equal("python-value", pythonContent.GetProperty("payload").GetString()); + Assert.Equal( + 3, + pythonContent.GetProperty("future_payload").GetProperty("nested").GetArrayLength()); + JsonElement persistedUnknownContent = document.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + Assert.True(JsonElement.DeepEquals(sourceUnknownContent, persistedUnknownContent)); + UsageDetails usage = Assert.IsType(response.ToResponse().Usage); + Assert.Equal(7, usage.AdditionalCounts?["providerCount"]); + Assert.Equal(11, usage.AdditionalCounts?["futureNumeric"]); + Assert.DoesNotContain("futureString", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureObject", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureArray", usage.AdditionalCounts?.Keys ?? []); + Assert.Contains("\"futureString\":\"seven\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureObject\":{\"count\":8}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureArray\":[9]", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"type\":\"future_python_content\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"payload\":\"python-value\"", roundTrip, StringComparison.Ordinal); + Assert.Equal(3, migrated.Data.IngestedPositions?["writer"]); + Assert.Equal("interop-fixture", migrated.ExtensionData?["rootProducer"].GetString()); + Assert.True(migrated.UnknownProperties?["futureRootProperty"].GetProperty("preserve").GetBoolean()); + Assert.Equal("python", migrated.Data.ExtensionData?["dataProducer"].GetString()); + Assert.True(migrated.Data.UnknownProperties?["futureDataProperty"].GetProperty("preserve").GetBoolean()); + Assert.True(document.RootElement.TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.TryGetProperty("futureRootProperty", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("futureDataProperty", out _)); + } + + [Fact] + public void OptionalRequestPropertiesAreOmittedWhenAbsent() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + }); + + string json = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.DoesNotContain("\"orchestrationId\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"responseType\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"expirationTimeUtc\"", json, StringComparison.Ordinal); + } } From 3b348484b6f0e20c1cf83bd9218c64aa7fc49852 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 10 Sep 2026 10:52:53 +0300 Subject: [PATCH 2/6] Add durable result mailbox schema foundation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 532fe4f5-939b-4962-989d-a1883dccd283 --- .../CHANGELOG.md | 1 + .../State/DurableAgentState.cs | 2 + .../DurableAgentStateCompletionReceipt.cs | 88 +++ .../State/DurableAgentStateContent.cs | 2 +- .../State/DurableAgentStateContract.cs | 21 + .../State/DurableAgentStateData.cs | 81 +++ .../State/DurableAgentStateHistoryBinding.cs | 49 ++ .../State/DurableAgentStateJsonContext.cs | 6 + .../State/DurableAgentStateJsonConverter.cs | 91 ++- .../State/DurableAgentStateMessage.cs | 39 +- .../State/DurableAgentStateSchemaVersion.cs | 4 +- .../State/DurableAgentStateTerminalError.cs | 38 + .../DurableAgentStateTerminalResponse.cs | 236 ++++++ .../State/DurableAgentStateTerminalResult.cs | 110 +++ .../State/README.md | 18 + ...oft.Agents.AI.DurableTask.UnitTests.csproj | 3 + .../State/DurableAgentStateMailboxTests.cs | 680 ++++++++++++++++++ .../State/DurableAgentStateTests.cs | 6 +- 18 files changed, 1462 insertions(+), 13 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 062e685..8ce824e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Fail durable workflows with a `MaxSuperstepsExceededException` when they reach the configurable `MaxSupersteps` limit with work still queued, instead of returning a successful partial result ([#84](https://github.com/microsoft/agent-framework-durable-extension/pull/84)) +- Added passive .NET DTO, converter, validation, and source-generation support for the proposed durable agent state 2.0 contract ([tamirdresher/agent-framework-durable-extension#1](https://github.com/tamirdresher/agent-framework-durable-extension/pull/1)) - Fixed `ConfigureDurableAgents` and `ConfigureDurableWorkflows` ignoring the `workerBuilder` or `clientBuilder` supplied to a later call when no earlier call supplied one, so the Durable Task worker and client are now registered whichever configuration call provides them. The first non-null delegate wins; later ones are still ignored so a builder passed to several calls is only applied once. Registering an agent that a workflow already referenced now promotes it to an explicitly registered agent instead of throwing, so agents and workflows can be configured in either order ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67)) - [BREAKING] Fixed `AddWorkflow` silently overwriting an existing workflow registered under the same name, which left the workflow and executor registries inconsistent. Registering a different workflow under a name that is already taken now throws, while re-registering the same workflow instance remains a no-op. An application that registers duplicate workflow names starts today but will now fail at startup ([#66](https://github.com/microsoft/agent-framework-durable-extension/pull/66)) - Fixed a `JsonTypeInfo metadata ... was not provided` failure when persisting agent state for function calls or results that carry values the state serializer has no metadata for, such as the `AIContent` results returned by MCP tools ([#57](https://github.com/microsoft/agent-framework-durable-extension/pull/57)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs index 1bcf6fd..48eaac9 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -12,6 +12,8 @@ namespace Microsoft.Agents.AI.DurableTask.State; internal sealed class DurableAgentState { internal const string CurrentSchemaVersion = "1.2.0"; + internal const string RevisedSchemaVersion = "2.0.0"; + internal const int RevisedSchemaMajorVersion = 2; private static readonly DurableAgentStateSchemaVersion s_currentSchemaVersion = DurableAgentStateSchemaVersion.ParseSupported(CurrentSchemaVersion); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs new file mode 100644 index 0000000..bfccea3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Immutable evidence that a correlation completed, retained independently from its result payload. +/// +internal sealed class DurableAgentStateCompletionReceipt +{ + public const string SucceededOutcome = "succeeded"; + public const string FailedOutcome = "failed"; + public const string AvailableResult = "available"; + public const string UnavailableResult = "unavailable"; + + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + [JsonPropertyName("outcome")] + public required string Outcome { get; init; } + + [JsonPropertyName("completedAt")] + public required DateTimeOffset CompletedAt { get; init; } + + [JsonPropertyName("resultState")] + public required string ResultState { get; init; } + + [JsonPropertyName("resultExpiresAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultExpiresAt { get; init; } + + [JsonPropertyName("resultUnavailableAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultUnavailableAt { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate(string dictionaryKey) + { + DurableAgentStateContract.ValidateIdentifier(dictionaryKey, "completionReceipts key"); + DurableAgentStateContract.ValidateIdentifier(this.CorrelationId, "completionReceipts.correlationId"); + if (!string.Equals(dictionaryKey, this.CorrelationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The durable agent state completion receipt key '{dictionaryKey}' does not match correlation ID '{this.CorrelationId}'."); + } + + if (this.Outcome is not SucceededOutcome and not FailedOutcome) + { + throw new InvalidOperationException( + $"The durable agent state completion outcome '{this.Outcome}' is not supported."); + } + + if (this.CompletedAt == default) + { + throw new InvalidOperationException( + "A durable agent completion receipt requires a completion timestamp."); + } + + if (this.ResultState is not AvailableResult and not UnavailableResult) + { + throw new InvalidOperationException( + $"The durable agent state result state '{this.ResultState}' is not supported."); + } + + if (this.ResultExpiresAt < this.CompletedAt) + { + throw new InvalidOperationException( + "The durable agent state result expiry cannot precede completion."); + } + + if (this.ResultState == AvailableResult && this.ResultUnavailableAt is not null) + { + throw new InvalidOperationException( + "An available durable agent result cannot have an unavailable timestamp."); + } + + if (this.ResultState == UnavailableResult && + (this.ResultUnavailableAt is null || this.ResultUnavailableAt < this.CompletedAt)) + { + throw new InvalidOperationException( + "An unavailable durable agent result requires an unavailable timestamp at or after completion."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs index 015e5f4..84b49e8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -97,7 +97,7 @@ protected static JsonElement ToJsonElement(object? value) return value switch { null => s_nullElement, - JsonElement element => element, + JsonElement element => element.Clone(), _ => JsonSerializer.SerializeToElement(value: value, jsonTypeInfo: s_objectTypeInfo) }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs new file mode 100644 index 0000000..7650198 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +internal static class DurableAgentStateContract +{ + public const int MaxIdentifierLength = 256; + public const int MaxMetadataKeyLength = 256; + public const int MaxMetadataStringLength = 16 * 1024; + + public static void ValidateIdentifier(string? value, string propertyName) + { + if (string.IsNullOrWhiteSpace(value) || + value.EnumerateRunes().Take(MaxIdentifierLength + 1).Count() > MaxIdentifierLength || + value.Any(char.IsControl)) + { + throw new InvalidOperationException( + $"The durable agent state '{propertyName}' property must be a non-empty string of at most {MaxIdentifierLength} characters without control characters."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index 5476176..a5b0915 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -17,6 +17,27 @@ internal sealed class DurableAgentStateData [JsonPropertyName("conversationHistory")] public IList ConversationHistory { get; init; } = []; + /// + /// Gets immutable terminal result payloads indexed by correlation ID. + /// + [JsonPropertyName("terminalResults")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? TerminalResults { get; init; } + + /// + /// Gets completion receipts retained independently from result payload expiry. + /// + [JsonPropertyName("completionReceipts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? CompletionReceipts { get; init; } + + /// + /// Gets the fixed logical history ownership binding for this durable session. + /// + [JsonPropertyName("historyBinding")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateHistoryBinding? HistoryBinding { get; init; } + /// /// Gets or sets the serialized inner agent session. /// @@ -62,4 +83,64 @@ internal sealed class DurableAgentStateData /// [JsonExtensionData] public IDictionary? UnknownProperties { get; set; } + + public void Validate(string schemaVersion) + { + DurableAgentStateSchemaVersion version = + DurableAgentStateSchemaVersion.ParseSupported(schemaVersion); + if (version.Major == DurableAgentState.RevisedSchemaMajorVersion) + { + if (this.ConversationHistory is null) + { + throw new InvalidOperationException( + "A revised durable agent state requires a conversation history collection."); + } + + if (this.HistoryBinding is null || + this.TerminalResults is null || + this.CompletionReceipts is null) + { + throw new InvalidOperationException( + "A revised durable agent state requires history binding, terminal results, and completion receipts."); + } + + this.HistoryBinding.Validate(); + foreach ((string correlationId, DurableAgentStateTerminalResult result) in this.TerminalResults) + { + result.Validate(correlationId); + if (!this.CompletionReceipts.TryGetValue(correlationId, out DurableAgentStateCompletionReceipt? receipt)) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{correlationId}' has no completion receipt."); + } + + if (receipt.ResultState != DurableAgentStateCompletionReceipt.AvailableResult || + receipt.Outcome != result.Outcome || + receipt.CompletedAt != result.CompletedAt || + receipt.ResultExpiresAt != result.ResultExpiresAt) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{correlationId}' is inconsistent with its completion receipt."); + } + } + + foreach ((string correlationId, DurableAgentStateCompletionReceipt receipt) in this.CompletionReceipts) + { + receipt.Validate(correlationId); + bool hasResult = this.TerminalResults.ContainsKey(correlationId); + if (receipt.ResultState == DurableAgentStateCompletionReceipt.AvailableResult != hasResult) + { + throw new InvalidOperationException( + $"Durable agent completion receipt '{correlationId}' is inconsistent with result availability."); + } + } + } + else if (this.TerminalResults is not null || + this.CompletionReceipts is not null || + this.HistoryBinding is not null) + { + throw new InvalidOperationException( + "Mailbox and fixed-history binding fields require durable agent state schema version 2.x."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs new file mode 100644 index 0000000..ff5d086 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Binds a durable session to one logical history owner for its lifetime. +/// +internal sealed class DurableAgentStateHistoryBinding +{ + public const int CurrentVersion = 1; + public const string DurableStateOwner = "durableState"; + public const string HistoryProviderOwner = "historyProvider"; + public const string ModelServiceOwner = "modelService"; + + [JsonPropertyName("version")] + [JsonRequired] + public int Version { get; init; } = CurrentVersion; + + [JsonPropertyName("ownerKind")] + public required string OwnerKind { get; init; } + + [JsonPropertyName("providerKey")] + public required string ProviderKey { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate() + { + if (this.Version != CurrentVersion) + { + throw new InvalidOperationException( + $"The durable agent state history binding version '{this.Version}' is not supported."); + } + + if (this.OwnerKind is not DurableStateOwner and + not HistoryProviderOwner and + not ModelServiceOwner) + { + throw new InvalidOperationException( + $"The durable agent state history owner kind '{this.OwnerKind}' is not supported."); + } + + DurableAgentStateContract.ValidateIdentifier(this.ProviderKey, "historyBinding.providerKey"); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index 87a8dc5..e60dbac 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -15,9 +15,15 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(DurableAgentStateCompaction))] [JsonSerializable(typeof(DurableAgentStateMessage))] [JsonSerializable(typeof(DurableAgentStateTruncation))] +[JsonSerializable(typeof(DurableAgentStateHistoryBinding))] +[JsonSerializable(typeof(DurableAgentStateCompletionReceipt))] +[JsonSerializable(typeof(DurableAgentStateTerminalResult))] +[JsonSerializable(typeof(DurableAgentStateTerminalResponse))] +[JsonSerializable(typeof(DurableAgentStateTerminalError))] // Function call and result content [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(object))] [JsonSerializable(typeof(JsonDocument))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonNode))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index 17fd8d3..f382c61 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -43,6 +43,14 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter? extensionData = element.Value.TryGetProperty(ExtensionDataPropertyName, out JsonElement extensionDataElement) ? ReadExtensionData(extensionDataElement) @@ -64,7 +72,7 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter keys = new(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!keys.Add(property.Name)) + { + throw new InvalidOperationException( + $"The revised durable agent state 'data.{propertyName}' property contains duplicate correlation ID '{property.Name}'."); + } + } + } + + private static void ValidateTerminalMessages(JsonElement terminalResults) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (!result.Value.TryGetProperty("response", out JsonElement response) || + !response.TryGetProperty("messages", out JsonElement messages)) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{result.Name}' requires a response messages collection."); + } + + if (messages.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{result.Name}' contains a non-array response messages property."); + } + + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{result.Name}' contains a non-object message."); + } + + if (message.TryGetProperty("contents", out JsonElement contents) && + contents.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{result.Name}' contains a non-array message contents property."); + } + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index 3810486..8ed3b39 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -44,7 +44,11 @@ internal sealed class DurableAgentStateMessage /// Gets the contents of this message. /// [JsonPropertyName("contents")] - public IReadOnlyList Contents { get; init; } = []; + public IReadOnlyList Contents + { + get; + init => field = value ?? []; + } = []; /// /// Gets the role of the message sender (e.g., "user", "assistant", "system"). @@ -69,13 +73,34 @@ public static DurableAgentStateMessage FromChatMessage( ChatMessage message, string? generatedMessageId = null, ILogger? logger = null) + => FromChatMessage(message, generatedMessageId, requireJsonSafeMetadata: false, logger); + + internal static DurableAgentStateMessage FromTerminalChatMessage( + ChatMessage message, + string? generatedMessageId = null, + ILogger? logger = null) + => FromChatMessage(message, generatedMessageId, requireJsonSafeMetadata: true, logger); + + private static DurableAgentStateMessage FromChatMessage( + ChatMessage message, + string? generatedMessageId, + bool requireJsonSafeMetadata, + ILogger? logger) { - Dictionary? additionalProperties = message.AdditionalProperties? - .ToDictionary( - pair => pair.Key, - pair => JsonSerializer.SerializeToElement( - pair.Value, - DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)))); + Dictionary? additionalProperties = null; + if (message.AdditionalProperties is not null) + { + foreach ((string key, object? value) in message.AdditionalProperties) + { + JsonElement element = requireJsonSafeMetadata + ? DurableAgentStateTerminalResponse.ConvertMetadata(value, key) + : JsonSerializer.SerializeToElement( + value, + DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + additionalProperties ??= []; + additionalProperties[key] = element; + } + } return new DurableAgentStateMessage() { diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs index d156ca5..b124974 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.DurableTask.State; internal readonly record struct DurableAgentStateSchemaVersion(BigInteger Major, BigInteger Minor, BigInteger Patch) : IComparable { - private const int SupportedMajorVersion = 1; + private static readonly BigInteger[] s_supportedMajorVersions = [1, DurableAgentState.RevisedSchemaMajorVersion]; /// /// Parses and validates a supported durable agent state schema version. @@ -22,7 +22,7 @@ public static DurableAgentStateSchemaVersion ParseSupported(string? value) throw new InvalidOperationException("The durable agent state has an invalid 'schemaVersion' property."); } - if (version.Major != SupportedMajorVersion) + if (!s_supportedMajorVersions.Contains(version.Major)) { throw new InvalidOperationException($"The durable agent state schema version '{value}' is not supported."); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs new file mode 100644 index 0000000..bc27003 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// JSON-safe failure metadata for a terminal result. +/// +internal sealed class DurableAgentStateTerminalError +{ + [JsonPropertyName("code")] + public required string Code { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Details { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate() + { + DurableAgentStateContract.ValidateIdentifier(this.Code, "terminalResults.error.code"); + if (string.IsNullOrWhiteSpace(this.Message) || + this.Message.EnumerateRunes() + .Take(DurableAgentStateContract.MaxMetadataStringLength + 1) + .Count() > DurableAgentStateContract.MaxMetadataStringLength) + { + throw new InvalidOperationException( + $"The durable agent terminal error message must be non-empty, at most {DurableAgentStateContract.MaxMetadataStringLength} characters, and contain no control characters."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs new file mode 100644 index 0000000..5bdef00 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.State; + +#pragma warning disable MEAI001 // ResponseContinuationToken is part of the AgentResponse contract captured here. + +/// +/// Immutable, JSON-safe projection of the fields consumed from an . +/// +internal sealed class DurableAgentStateTerminalResponse +{ + [JsonPropertyName("messages")] + public IReadOnlyList Messages + { + get; + init => field = value ?? []; + } = []; + + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateUsage? Usage { get; init; } + + [JsonPropertyName("createdAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } + + [JsonPropertyName("responseId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResponseId { get; init; } + + [JsonPropertyName("agentId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AgentId { get; init; } + + [JsonPropertyName("finishReason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; init; } + + [JsonPropertyName("continuationToken")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContinuationToken { get; init; } + + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? AdditionalProperties { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public static DurableAgentStateTerminalResponse FromResponse( + AgentResponse response, + string correlationId, + DateTimeOffset completedAt, + ILogger? logger = null) + { + Dictionary? additionalProperties = null; + if (response.AdditionalProperties is not null) + { + foreach ((string key, object? value) in response.AdditionalProperties) + { + additionalProperties ??= []; + additionalProperties[key] = ConvertMetadata(value, key); + } + } + + return new() + { + Messages = response.Messages + .Select((message, index) => DurableAgentStateMessage.FromTerminalChatMessage( + message, + DurableAgentStateMessageIdentity.Create("result", correlationId, completedAt, index), + logger)) + .ToList(), + Usage = DurableAgentStateUsage.FromUsage(response.Usage), + CreatedAt = response.CreatedAt, + ResponseId = response.ResponseId, + AgentId = response.AgentId, + FinishReason = response.FinishReason?.Value, + ContinuationToken = response.ContinuationToken is null + ? null + : Convert.ToBase64String(response.ContinuationToken.ToBytes().Span), + AdditionalProperties = additionalProperties, + }; + } + + public AgentResponse ToResponse() + { + AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null + ? null + : new(this.AdditionalProperties.Select(pair => + new KeyValuePair(pair.Key, pair.Value))); + + return new AgentResponse + { + Messages = this.Messages.Select(message => message.ToChatMessage()).ToList(), + Usage = this.Usage?.ToUsageDetails(), + CreatedAt = this.CreatedAt, + ResponseId = this.ResponseId, + AgentId = this.AgentId, + FinishReason = this.FinishReason is null ? null : new ChatFinishReason(this.FinishReason), + ContinuationToken = this.ContinuationToken is null + ? null + : ResponseContinuationToken.FromBytes(Convert.FromBase64String(this.ContinuationToken)), + AdditionalProperties = additionalProperties, + }; + } + + public void Validate() + { + if (this.Messages is null) + { + throw new InvalidOperationException( + "A durable agent terminal response requires a messages collection."); + } + + foreach (DurableAgentStateMessage? message in this.Messages) + { + if (message is null || message.Contents is null) + { + throw new InvalidOperationException( + "A durable agent terminal response cannot contain null messages or content collections."); + } + + if (message.Role is not "user" and not "assistant" and not "system" and not "tool") + { + throw new InvalidOperationException( + $"The durable agent terminal response message role '{message.Role}' is not supported."); + } + + if (message.Contents.Any(static content => content is null)) + { + throw new InvalidOperationException( + "A durable agent terminal response cannot contain null content entries."); + } + } + + ValidateOptionalIdentifier(this.ResponseId, "terminalResults.response.responseId"); + ValidateOptionalIdentifier(this.AgentId, "terminalResults.response.agentId"); + ValidateOptionalIdentifier(this.FinishReason, "terminalResults.response.finishReason"); + + if (this.ContinuationToken is not null) + { + if (this.ContinuationToken.Length > DurableAgentStateContract.MaxMetadataStringLength) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token is too large."); + } + + try + { + byte[] decoded = Convert.FromBase64String(this.ContinuationToken); + if (!string.Equals( + this.ContinuationToken, + Convert.ToBase64String(decoded), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token must use canonical base64 encoding."); + } + } + catch (FormatException exception) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token must be base64 encoded.", + exception); + } + } + + if (this.AdditionalProperties is not null) + { + foreach (string key in this.AdditionalProperties.Keys) + { + DurableAgentStateContract.ValidateIdentifier(key, "terminalResults.response.extensionData key"); + } + } + } + + private static void ValidateOptionalIdentifier(string? value, string propertyName) + { + if (value is not null) + { + DurableAgentStateContract.ValidateIdentifier(value, propertyName); + } + } + + internal static JsonElement ConvertMetadata(object? value, string propertyName) + { + switch (value) + { + case null: + return JsonSerializer.SerializeToElement( + value, + DurableAgentStateJsonContext.Default.Object); + case JsonElement jsonElement: + return jsonElement.Clone(); + case string text when text.Length <= DurableAgentStateContract.MaxMetadataStringLength: + return JsonSerializer.SerializeToElement( + text, + DurableAgentStateJsonContext.Default.String); + case bool boolean: + return JsonSerializer.SerializeToElement( + boolean, + DurableAgentStateJsonContext.Default.Boolean); + case int integer: + return JsonSerializer.SerializeToElement( + integer, + DurableAgentStateJsonContext.Default.Int32); + case long longInteger: + return JsonSerializer.SerializeToElement( + longInteger, + DurableAgentStateJsonContext.Default.Int64); + case double doubleValue when double.IsFinite(doubleValue): + return JsonSerializer.SerializeToElement( + doubleValue, + DurableAgentStateJsonContext.Default.Double); + case decimal decimalValue: + return JsonSerializer.SerializeToElement( + decimalValue, + DurableAgentStateJsonContext.Default.Decimal); + case DateTimeOffset dateTimeOffset: + return JsonSerializer.SerializeToElement( + dateTimeOffset, + DurableAgentStateJsonContext.Default.DateTimeOffset); + default: + throw new InvalidOperationException( + $"The AgentResponse metadata property '{propertyName}' has unsupported runtime type '{value?.GetType()}'."); + } + } + +#pragma warning restore MEAI001 +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs new file mode 100644 index 0000000..dbbd4ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Immutable terminal result envelope detached from evictable conversation history. +/// +internal sealed class DurableAgentStateTerminalResult +{ + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + [JsonPropertyName("outcome")] + public required string Outcome { get; init; } + + [JsonPropertyName("completedAt")] + public required DateTimeOffset CompletedAt { get; init; } + + [JsonPropertyName("resultExpiresAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultExpiresAt { get; init; } + + [JsonPropertyName("response")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTerminalResponse? Response { get; init; } + + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTerminalError? Error { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public static DurableAgentStateTerminalResult FromResponse( + string correlationId, + AgentResponse response, + DateTimeOffset completedAt, + DateTimeOffset? resultExpiresAt = null, + ILogger? logger = null) + { + DurableAgentStateContract.ValidateIdentifier(correlationId, "terminalResults.correlationId"); + return new() + { + CorrelationId = correlationId, + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultExpiresAt = resultExpiresAt, + Response = DurableAgentStateTerminalResponse.FromResponse( + response, + correlationId, + completedAt, + logger), + }; + } + + public void Validate(string dictionaryKey) + { + DurableAgentStateContract.ValidateIdentifier(dictionaryKey, "terminalResults key"); + DurableAgentStateContract.ValidateIdentifier(this.CorrelationId, "terminalResults.correlationId"); + if (!string.Equals(dictionaryKey, this.CorrelationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The durable agent state terminal result key '{dictionaryKey}' does not match correlation ID '{this.CorrelationId}'."); + } + + if (this.Outcome is not DurableAgentStateCompletionReceipt.SucceededOutcome and + not DurableAgentStateCompletionReceipt.FailedOutcome) + { + throw new InvalidOperationException( + $"The durable agent state terminal outcome '{this.Outcome}' is not supported."); + } + + if (this.CompletedAt == default) + { + throw new InvalidOperationException( + "A durable agent terminal result requires a completion timestamp."); + } + + if (this.Response is null) + { + throw new InvalidOperationException( + "A durable agent terminal result must contain a response payload."); + } + + if (this.Outcome == DurableAgentStateCompletionReceipt.SucceededOutcome && this.Error is not null) + { + throw new InvalidOperationException( + "A successful durable agent terminal result cannot contain error metadata."); + } + + if (this.Outcome == DurableAgentStateCompletionReceipt.FailedOutcome && this.Error is null) + { + throw new InvalidOperationException( + "A failed durable agent terminal result must contain error metadata."); + } + + if (this.ResultExpiresAt < this.CompletedAt) + { + throw new InvalidOperationException( + "The durable agent terminal result expiry cannot precede completion."); + } + + this.Response.Validate(); + this.Error?.Validate(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 46d82bf..93a7d06 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -45,6 +45,24 @@ ignored by the runtime projection. Malformed known count fields fail deserializa 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. +## Revised execution-state foundation + +The mailbox and fixed-history binding contracts use schema `2.0.0`. This is intentionally a fail-closed major +version: a 1.x worker preserves unknown fields but does not understand completion receipts, so allowing it to +process revised state could rerun work whose transcript result was already removed. The .NET reader accepts +legacy 1.x state and revised 2.x state, but new state continues to default to `1.2.0`; this schema-only layer +does not activate revised writes. A later execution layer must opt into `2.0.0` only when it writes the complete +mailbox and binding layout. + +In revised state, `terminalResults` stores immutable result envelopes by correlation ID outside +`conversationHistory`, while `completionReceipts` retains completion evidence after a result payload expires. +No receipt means pending; an `available` receipt requires a matching result; an `unavailable` receipt proves +completion without a result payload. `historyBinding` records a versioned owner kind and stable logical provider +key. The key is explicit wire identity and must not be inferred from CLR type names or opaque session keys. + +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. + ## Sample State ```json diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index d3c993b..8d45d82 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -14,6 +14,9 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs new file mode 100644 index 0000000..0cee786 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs @@ -0,0 +1,680 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateMailboxTests +{ + [Fact] + public void LegacyStateRoundTripsWithoutRevisedFields() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [] + } + } + """; + + DurableAgentState state = Deserialize(Json); + string roundTrip = Serialize(state); + + Assert.DoesNotContain("\"terminalResults\"", roundTrip, StringComparison.Ordinal); + Assert.DoesNotContain("\"completionReceipts\"", roundTrip, StringComparison.Ordinal); + Assert.DoesNotContain("\"historyBinding\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void RevisedFixtureRoundTripsTypedMailboxAndFutureFields() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")); + + DurableAgentState state = Deserialize(json); + string roundTrip = Serialize(state); + DurableAgentStateTerminalResult result = Assert.IsType( + state.Data.TerminalResults?["corr-2"]); + DurableAgentStateCompletionReceipt unavailable = Assert.IsType( + state.Data.CompletionReceipts?["corr-expired"]); + + Assert.Equal(DurableAgentState.RevisedSchemaVersion, state.SchemaVersion); + Assert.Equal("contoso.support-history.v1", state.Data.HistoryBinding?.ProviderKey); + Assert.Equal("response-id-2", result.Response?.ResponseId); + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, unavailable.ResultState); + Assert.Contains("\"futureResponseField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureReceiptField\":7", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureBindingField\":\"preserve\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureRootField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); + + DurableAgentState clone = state.Clone(); + Assert.Equal(DurableAgentState.RevisedSchemaVersion, clone.SchemaVersion); + Assert.Equal("response-id-2", clone.Data.TerminalResults?["corr-2"].Response?.ResponseId); + } + + [Theory] + [InlineData("terminalResults")] + [InlineData("completionReceipts")] + [InlineData("historyBinding")] + public void RevisedStateRequiresCompleteLayout(string missingProperty) + { + Dictionary data = new() + { + ["conversationHistory"] = Array.Empty(), + ["terminalResults"] = new Dictionary(), + ["completionReceipts"] = new Dictionary(), + ["historyBinding"] = new + { + version = 1, + ownerKind = DurableAgentStateHistoryBinding.DurableStateOwner, + providerKey = "durable-state.v1", + }, + }; + _ = data.Remove(missingProperty); + string json = JsonSerializer.Serialize(new + { + schemaVersion = DurableAgentState.RevisedSchemaVersion, + data, + }); + + Assert.Throws(() => Deserialize(json)); + } + + [Theory] + [InlineData("conversationHistory")] + [InlineData("terminalResults.messages")] + public void RevisedStateRejectsNullRequiredCollections(string collection) + { + string json = collection == "conversationHistory" + ? """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": null, + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """ + : """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "correlation": { + "correlationId": "correlation", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": null } + } + }, + "completionReceipts": { + "correlation": { + "correlationId": "correlation", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalMessageMayOmitContentsButCannotUseNullEntries() + { + const string MetadataOnlyJson = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [{ "role": "assistant" }] } + } + }, + "completionReceipts": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + const string NullMessageJson = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [null] } + } + }, + "completionReceipts": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + AgentResponse response = Assert.IsType( + Deserialize(MetadataOnlyJson).Data.TerminalResults?["metadata"].Response).ToResponse(); + Assert.Empty(Assert.Single(response.Messages).Contents); + Assert.Throws(() => Deserialize(NullMessageJson)); + } + + [Fact] + public void UnknownMailboxDiscriminatorIsRejected() + { + string json = CreateRevisedJson( + resultOutcome: "futureOutcome", + receiptOutcome: "futureOutcome", + resultState: DurableAgentStateCompletionReceipt.AvailableResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void DuplicateCompletionCorrelationIsRejected() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "duplicate": { + "correlationId": "duplicate", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-10T05:00:01+00:00" + }, + "duplicate": { + "correlationId": "duplicate", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-10T05:00:01+00:00" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Theory] + [InlineData("available", false)] + [InlineData("unavailable", true)] + public void ResultAndReceiptAvailabilityMustBeConsistent(string resultState, bool includeResult) + { + string json = CreateRevisedJson( + resultOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + receiptOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + resultState, + includeResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void ResultAndReceiptMetadataMustMatch() + { + string json = CreateRevisedJson( + resultOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + receiptOutcome: DurableAgentStateCompletionReceipt.FailedOutcome, + resultState: DurableAgentStateCompletionReceipt.AvailableResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void FailedTerminalResultWithMatchingReceiptIsValid() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "failed": { + "correlationId": "failed", + "outcome": "failed", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { + "messages": [{ + "role": "assistant", + "contents": [{ + "$type": "error", + "message": "failed", + "errorCode": "Example" + }] + }] + }, + "error": { + "code": "Example", + "message": "The operation failed." + } + } + }, + "completionReceipts": { + "failed": { + "correlationId": "failed", + "outcome": "failed", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal( + "Example", + state.Data.TerminalResults?["failed"].Error?.Code); + } + + [Fact] + public void TerminalErrorLengthCountsUnicodeScalars() + { + DurableAgentState state = CreateEmptyRevisedState(new() + { + OwnerKind = DurableAgentStateHistoryBinding.DurableStateOwner, + ProviderKey = "durable-state.v1", + }); + const string CorrelationId = "failed"; + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:00+00:00"); + state.Data.TerminalResults![CorrelationId] = new() + { + CorrelationId = CorrelationId, + Outcome = DurableAgentStateCompletionReceipt.FailedOutcome, + CompletedAt = completedAt, + Response = new(), + Error = new() + { + Code = "Example", + Message = string.Concat(Enumerable.Repeat("\U0001F600", 10_000)), + }, + }; + state.Data.CompletionReceipts![CorrelationId] = new() + { + CorrelationId = CorrelationId, + Outcome = DurableAgentStateCompletionReceipt.FailedOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }; + + string json = Serialize(state); + + Assert.Equal( + 10_000, + Deserialize(json).Data.TerminalResults![CorrelationId].Error!.Message.EnumerateRunes().Count()); + } + + [Fact] + public void TerminalResponseMetadataRequiresValidKeys() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace("\"region\": \"test\"", "\"\": \"test\"", StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Theory] + [InlineData(2, DurableAgentStateHistoryBinding.DurableStateOwner, "durable-state.v1")] + [InlineData(1, "futureOwner", "provider.v1")] + [InlineData(1, DurableAgentStateHistoryBinding.HistoryProviderOwner, " ")] + public void InvalidHistoryBindingIsRejected(int version, string ownerKind, string providerKey) + { + DurableAgentState state = CreateEmptyRevisedState(new() + { + Version = version, + OwnerKind = ownerKind, + ProviderKey = providerKey, + }); + + Assert.Throws(() => Serialize(state)); + } + + [Fact] + public void HistoryBindingRequiresExplicitWireVersion() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace("\"version\": 1,", string.Empty, StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalResponsePreservesConsumerFieldsWithoutRuntimeObjects() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"); + ChatMessage message = new( + ChatRole.Assistant, + [ + new TextContent("done"), + new UriContent("https://example.test/result.json", "application/json"), + ]) + { + MessageId = "message-id", + AuthorName = "agent", + }; + AgentResponse response = new([message]) + { + CreatedAt = completedAt, + ResponseId = "response-id", + AgentId = "agent-id", + FinishReason = new ChatFinishReason("stop"), + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), + Usage = new UsageDetails + { + InputTokenCount = 4, + OutputTokenCount = 2, + TotalTokenCount = 6, + }, + AdditionalProperties = new() + { + ["region"] = "test", + ["attempt"] = 2, + }, + RawRepresentation = new object(), + }; + + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + completedAt); + AgentResponse restored = Assert.IsType(stored.Response).ToResponse(); + + Assert.Equal("response-id", restored.ResponseId); + Assert.Equal("agent-id", restored.AgentId); + Assert.Equal("stop", restored.FinishReason?.Value); + Assert.Equal(completedAt, restored.CreatedAt); + Assert.Equal([1, 2, 3], restored.ContinuationToken?.ToBytes().ToArray()); + Assert.Equal(6, restored.Usage?.TotalTokenCount); + Assert.Equal("test", Assert.IsType(restored.AdditionalProperties?["region"]).GetString()); + Assert.Equal(2, Assert.IsType(restored.AdditionalProperties?["attempt"]).GetInt32()); + Assert.Null(restored.RawRepresentation); + ChatMessage restoredMessage = Assert.Single(restored.Messages); + Assert.Equal("message-id", restoredMessage.MessageId); + Assert.Collection( + restoredMessage.Contents, + content => Assert.Equal("done", Assert.IsType(content).Text), + content => + { + UriContent uri = Assert.IsType(content); + Assert.Equal("https://example.test/result.json", uri.Uri.ToString()); + Assert.Equal("application/json", uri.MediaType); + }); + } + + [Fact] + public void TerminalResponseRejectsArbitraryRuntimeMetadata() + { + AgentResponse response = new() + { + AdditionalProperties = new() + { + ["unsupported"] = new object(), + }, + }; + + InvalidOperationException exception = Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + + Assert.Contains("unsupported runtime type", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponseRejectsArbitraryMessageMetadata() + { + ChatMessage message = new(ChatRole.Assistant, "done") + { + AdditionalProperties = new() + { + ["unsupported"] = new object(), + }, + }; + + InvalidOperationException exception = Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse([message]), + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + + Assert.Contains("unsupported runtime type", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponseRejectsRuntimeBackedJsonNodeMetadata() + { + AgentResponse response = new() + { + AdditionalProperties = new() + { + ["unsupported"] = JsonValue.Create(new Dictionary + { + ["runtimeValue"] = 42, + }), + }, + }; + + Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + } + + [Fact] + public void NonCanonicalContinuationTokenIsRejected() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace("\"AQID\"", "\"AQ ID\"", StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalResultClonesJsonElementContent() + { + DurableAgentStateTerminalResult result; + using (JsonDocument document = JsonDocument.Parse("""{"value":1}""")) + { + ChatMessage message = new( + ChatRole.Assistant, + [new FunctionResultContent("call-1", document.RootElement)]); + result = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse([message]), + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00")); + } + + AgentResponse restored = Assert.IsType(result.Response).ToResponse(); + FunctionResultContent content = + Assert.IsType(Assert.Single(Assert.Single(restored.Messages).Contents)); + Assert.Equal(1, Assert.IsType(content.Result).GetProperty("value").GetInt32()); + } + + [Fact] + public void TerminalResultIsDetachedFromTranscriptAndSourceResponse() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"); + ChatMessage sourceMessage = new(ChatRole.Assistant, "original"); + AgentResponse response = new([sourceMessage]); + DurableAgentStateTerminalResult result = DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + completedAt); + DurableAgentStateResponse transcript = DurableAgentStateResponse.FromResponse("correlation", response); + + sourceMessage.Contents.Clear(); + transcript.Messages[0].MessageId = "transcript-mutated"; + + DurableAgentStateMessage resultMessage = + Assert.Single(Assert.IsType(result.Response).Messages); + Assert.Single(resultMessage.Contents); + Assert.Equal("durable_result_correlation_0", resultMessage.MessageId); + } + + [Fact] + public void VersionOneStateCannotWriteRevisedFields() + { + DurableAgentState state = new() + { + Data = new() + { + TerminalResults = new Dictionary(), + }, + }; + + Assert.Throws(() => Serialize(state)); + } + + [Fact] + public void IdentifierLengthCountsUnicodeScalars() + { + string providerKey = string.Concat(Enumerable.Repeat("\U0001F600", 200)); + DurableAgentState state = CreateEmptyRevisedState(new() + { + OwnerKind = DurableAgentStateHistoryBinding.HistoryProviderOwner, + ProviderKey = providerKey, + }); + + string json = Serialize(state); + DurableAgentState restored = Deserialize(json); + + Assert.Equal(providerKey, restored.Data.HistoryBinding?.ProviderKey); + } + + private static DurableAgentState CreateEmptyRevisedState(DurableAgentStateHistoryBinding binding) + { + return new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new() + { + ConversationHistory = [], + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + HistoryBinding = binding, + }, + }; + } + + private static string CreateRevisedJson( + string resultOutcome, + string receiptOutcome, + string resultState, + bool includeResult = true) + { + string result = includeResult + ? $$""" + "correlation": { + "correlationId": "correlation", + "outcome": "{{resultOutcome}}", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [] } + } + """ + : string.Empty; + string unavailableAt = resultState == DurableAgentStateCompletionReceipt.UnavailableResult + ? """, "resultUnavailableAt": "2026-09-10T05:00:01+00:00" """ + : string.Empty; + + return $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { {{result}} }, + "completionReceipts": { + "correlation": { + "correlationId": "correlation", + "outcome": "{{receiptOutcome}}", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "{{resultState}}"{{unavailableAt}} + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + } + + private static DurableAgentState Deserialize(string json) => + Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + private static string Serialize(DurableAgentState state) => + JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs index e829ca6..6e6a8ad 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -14,6 +14,8 @@ public void NewStateDefaultsToCurrentSchemaVersion() DurableAgentState state = new(); Assert.Equal(DurableAgentState.CurrentSchemaVersion, state.SchemaVersion); + Assert.Equal("1.2.0", state.SchemaVersion); + Assert.Equal("2.0.0", DurableAgentState.RevisedSchemaVersion); } [Fact] @@ -122,12 +124,12 @@ public void InvalidSchemaVersionCannotBeSerialized() } [Fact] - public void BreakingVersion() + public void UnsupportedMajorVersion() { // Arrange const string JsonText = """ { - "schemaVersion": "2.0.0" + "schemaVersion": "3.0.0" } """; From 394eb158e713ffd70236feb163efc3c56ad2bad6 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 10 Sep 2026 17:24:21 +0300 Subject: [PATCH 3/6] Clarify durable state schema trust boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 532fe4f5-939b-4962-989d-a1883dccd283 --- .../State/DurableAgentStateData.cs | 65 ++++++++++++++++--- .../State/DurableAgentStateJsonConverter.cs | 12 ++++ .../State/DurableAgentStateMessage.cs | 10 ++- .../DurableAgentStateTerminalResponse.cs | 9 +++ .../State/DurableAgentStateTruncation.cs | 12 ++-- .../State/README.md | 6 ++ ...oft.Agents.AI.DurableTask.UnitTests.csproj | 4 +- .../State/DurableAgentStateTests.cs | 52 ++++++++++++++- 8 files changed, 152 insertions(+), 18 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index a5b0915..3f2ca16 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -39,26 +39,71 @@ internal sealed class DurableAgentStateData public DurableAgentStateHistoryBinding? HistoryBinding { get; init; } /// - /// Gets or sets the serialized inner agent session. + /// Gets or sets the opaque state produced by the configured agent's session serialization contract. /// + /// + /// This value can contain service conversation identity, continuation state, and provider-specific + /// state that cannot be reduced to a conversation ID. The durable state layer owns only the JSON + /// representation: it requires an object, clones assigned values away from caller-owned + /// instances, and round-trips the object without interpreting property + /// names such as $type or $runtimeType. It never uses this JSON to select or construct a + /// CLR type. A later integration layer may return the object only to the configured agent through + /// that agent's session deserialization contract. + /// + /// The normal System.Text.Json nesting limit applies when the enclosing state is parsed. + /// This schema layer intentionally has no independent byte cap because valid opaque provider state can + /// vary in size; the durable entity storage budget and retention policy remain the outer trust boundary. + /// Producers must therefore treat session state as persisted data, not as a trusted instruction or an + /// object graph. + /// [JsonPropertyName("session")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Session { get; set; } + public JsonElement? Session + { + get; + set + { + if (value is not JsonElement element) + { + field = null; + return; + } + + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.session' property must be a JSON object."); + } + + field = element.Clone(); + } + } /// - /// Gets or sets the highest workflow conversation position ingested from each executor. + /// Gets or sets the highest legacy scalar conversation position ingested from each workflow producer. /// /// - /// The .NET workflow path does not populate these watermarks yet, but they are preserved for - /// cross-language schema compatibility. + /// This field records the legacy scalar-watermark design for workflow producers: a compatible producer + /// can use the highest known contiguous position to avoid redelivering messages it already incorporated. + /// It is distinct from the exact completion-receipt design used for terminal delivery. The current .NET + /// and Python production paths do not produce or consume these values; .NET preserves and round-trips + /// them so state written by a compatible workflow implementation is not discarded. /// [JsonPropertyName("ingestedPositions")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? IngestedPositions { get; set; } /// - /// Gets or sets bounded evidence that retention removed conversation messages. + /// Gets or sets bounded evidence that transcript messages were removed from durable state. /// + /// + /// The evidence persists after the corresponding transcript entries are gone and records the cumulative + /// count plus the first and latest eviction times. This lets readers and operators distinguish an + /// intentionally truncated transcript from one in which the missing messages were never persisted. + /// It is diagnostic provenance only: it is not model context, a terminal result, or proof that a + /// correlation completed. This layer preserves the contract but does not currently produce or consume + /// truncation evidence. + /// [JsonPropertyName("truncation")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DurableAgentStateTruncation? Truncation { get; set; } @@ -72,14 +117,18 @@ internal sealed class DurableAgentStateData public DateTime? ExpirationTimeUtc { get; set; } /// - /// Gets application-defined data-level metadata from the schema's extensionData property. + /// Gets producer-defined values from the schema's declared data-level extensionData field. /// + /// + /// This is an explicit interoperability field. It is separate from , + /// which captures undeclared future JSON members through . + /// [JsonPropertyName("extensionData")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? ExtensionData { get; init; } /// - /// Gets unknown data properties that are outside the declared schema. + /// Gets undeclared future data properties that appear beside the schema's known fields. /// [JsonExtensionData] public IDictionary? UnknownProperties { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index f382c61..0949692 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -43,6 +43,7 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter - /// Gets message-level additional properties from the schema's extensionData property. + /// Gets producer-defined message values from the schema's declared extensionData field. /// + /// + /// The CLR name mirrors so conversion does not invent a + /// second metadata vocabulary. The wire name remains extensionData for cross-language schema + /// compatibility. This declared field is distinct from , which captures + /// undeclared future members adjacent to the message's known JSON fields. + /// [JsonPropertyName("extensionData")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? AdditionalProperties { get; init; } @@ -57,7 +63,7 @@ public IReadOnlyList Contents public required string Role { get; init; } /// - /// Gets unknown message properties that are outside the declared schema. + /// Gets undeclared future message properties that appear beside the schema's known fields. /// [JsonExtensionData] public IDictionary? UnknownProperties { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs index 5bdef00..ea5a10d 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs @@ -45,6 +45,15 @@ public IReadOnlyList Messages [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ContinuationToken { get; init; } + /// + /// Gets JSON-safe producer-defined values from the declared wire-level extensionData field. + /// + /// + /// The CLR name mirrors . Keeping that name makes the + /// projection boundary explicit while preserves the shared + /// schema name. This field is not the same as , which contains + /// undeclared future JSON members. + /// [JsonPropertyName("extensionData")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? AdditionalProperties { get; init; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs index c71b9d5..5bf46bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs @@ -5,24 +5,28 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// -/// Bounded evidence that durable conversation entries were removed by retention. +/// Bounded diagnostic evidence that durable transcript messages were removed. /// +/// +/// This object survives the removed entries so a persisted gap is not mistaken for history that never +/// existed. It does not contain model context and does not establish request delivery or completion. +/// internal sealed class DurableAgentStateTruncation { /// - /// Gets or sets the total number of messages removed over the lifetime of the session. + /// Gets or sets the cumulative number of transcript messages known to have been removed. /// [JsonPropertyName("evictedMessageCount")] public int EvictedMessageCount { get; set; } /// - /// Gets or sets when the first eviction occurred. + /// Gets or sets when transcript removal was first recorded. /// [JsonPropertyName("firstEvictedAt")] public DateTimeOffset FirstEvictedAt { get; set; } /// - /// Gets or sets when the latest eviction occurred. + /// Gets or sets when transcript removal was most recently recorded. /// [JsonPropertyName("lastEvictedAt")] public DateTimeOffset LastEvictedAt { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 93a7d06..4adcd4a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -63,6 +63,12 @@ key. The key is explicit wire identity and must not be inferred from CLR type na 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. +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 +gating before a 2.0 producer is enabled; preserving unknown fields alone is not sufficient because an +unaware worker could ignore completion receipts and rerun completed work. + ## Sample State ```json diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index 8d45d82..30ca1da 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -11,8 +11,8 @@ - ( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + JsonElement session = Assert.IsType(restored.Data.Session); + Assert.Equal(JsonValueKind.Object, session.ValueKind); + Assert.Equal("service-1", session.GetProperty("conversationId").GetString()); + Assert.Equal( + "Untrusted.Type, Untrusted.Assembly", + session.GetProperty("$runtimeType").GetString()); + Assert.IsType(restored.Data.Session); + } + + [Theory] + [InlineData("null")] + [InlineData("\"session\"")] + [InlineData("[]")] + [InlineData("42")] + public void OpaqueSessionMustBeAJsonObject(string sessionJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "session": {{sessionJson}} + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + [Fact] public void DeclaredExtensionDataAndUnknownPropertiesRoundTripIndependently() { @@ -456,10 +504,10 @@ public void FutureCompatibleVersionAndUnknownFieldsSurviveMutationAndRoundTrip() } [Fact] - public void PythonPr59ShapeFixtureMigratesIdsAndPreservesExtensions() + public void SharedPythonShapeFixtureMigratesIdsAndPreservesExtensions() { string json = File.ReadAllText( - Path.Combine(AppContext.BaseDirectory, "Fixtures", "python-durable-agent-state-1.2.json")); + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-1.2-python-shape.json")); using JsonDocument sourceDocument = JsonDocument.Parse(json); JsonElement sourceUnknownContent = sourceDocument.RootElement.GetProperty("data") .GetProperty("conversationHistory")[1] From 70e43f0efb3c735a636a6755d2eba9921ddc0633 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Fri, 11 Sep 2026 17:39:31 +0300 Subject: [PATCH 4/6] Align .NET state support with schema 2.0 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 532fe4f5-939b-4962-989d-a1883dccd283 --- .../State/DurableAgentState.cs | 4 +- .../State/DurableAgentStateContent.cs | 7 + .../State/DurableAgentStateData.cs | 63 +- .../State/DurableAgentStateEntry.cs | 38 +- .../State/DurableAgentStateErrorContent.cs | 21 +- .../DurableAgentStateFunctionCallContent.cs | 63 +- .../DurableAgentStateFunctionResultContent.cs | 15 +- .../State/DurableAgentStateHistoryBinding.cs | 8 +- .../State/DurableAgentStateJsonContext.cs | 1 + .../State/DurableAgentStateJsonConverter.cs | 638 ++++++++++++++++- .../State/DurableAgentStateMessage.cs | 24 + .../State/DurableAgentStateMessageIdentity.cs | 6 +- .../State/DurableAgentStateSchemaVersion.cs | 16 +- .../State/DurableAgentStateTerminalError.cs | 8 +- .../DurableAgentStateTerminalResponse.cs | 29 +- .../State/DurableAgentStateTerminalResult.cs | 7 + .../State/DurableAgentStateTruncation.cs | 19 + .../State/DurableAgentStateUriContent.cs | 9 +- .../State/README.md | 29 +- ...oft.Agents.AI.DurableTask.UnitTests.csproj | 6 + ...rableAgentStateFunctionCallContentTests.cs | 21 + .../State/DurableAgentStateMailboxTests.cs | 661 +++++++++++++++++- .../State/DurableAgentStateTests.cs | 42 +- 23 files changed, 1604 insertions(+), 131 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs index 48eaac9..9bb1770 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -29,8 +29,8 @@ internal sealed class DurableAgentState /// /// New states default to . Deserialization assigns the /// persisted value through this init-only property, and constructs a new - /// state when an older compatible version must be promoted for a write. Future compatible - /// versions are preserved rather than rewritten. + /// state when an older declared version must be promoted for a legacy write. Only exact schema + /// snapshots reviewed by the shared contract are accepted; later versions fail closed. /// [JsonPropertyName("schemaVersion")] public string SchemaVersion { get; init; } = CurrentSchemaVersion; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs index 84b49e8..76aac72 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -54,6 +54,13 @@ internal abstract class DurableAgentStateContent /// A converted instance. public abstract AIContent ToAIContent(); + /// + /// Validates semantic constraints introduced by the schema 2.0 contract. + /// + public virtual void ValidateV2() + { + } + /// /// Creates a from an . /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index 3f2ca16..a2e9991 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -32,8 +32,12 @@ internal sealed class DurableAgentStateData public IDictionary? CompletionReceipts { get; init; } /// - /// Gets the fixed logical history ownership binding for this durable session. + /// Gets an optional, provisional descriptor for the configured history facility. /// + /// + /// This shared DTO does not establish effective per-run ownership or prohibit ownership transitions. + /// A C# hosting profile may apply stricter policy in a later layer. + /// [JsonPropertyName("historyBinding")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DurableAgentStateHistoryBinding? HistoryBinding { get; init; } @@ -80,14 +84,14 @@ public JsonElement? Session } /// - /// Gets or sets the highest legacy scalar conversation position ingested from each workflow producer. + /// Gets or sets the highest legacy scalar conversation position seen from each workflow producer. /// /// - /// This field records the legacy scalar-watermark design for workflow producers: a compatible producer - /// can use the highest known contiguous position to avoid redelivering messages it already incorporated. - /// It is distinct from the exact completion-receipt design used for terminal delivery. The current .NET - /// and Python production paths do not produce or consume these values; .NET preserves and round-trips - /// them so state written by a compatible workflow implementation is not discarded. + /// This field records only the greatest observed position. It does not prove a contiguous delivered + /// prefix: after seeing positions 1 and 3, the scalar value 3 does not establish that position 2 was + /// delivered. It is distinct from the exact completion-receipt design used for terminal delivery. + /// The current .NET and Python production paths do not produce or consume these values; .NET preserves + /// and round-trips them so state written by a compatible workflow implementation is not discarded. /// [JsonPropertyName("ingestedPositions")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -137,6 +141,14 @@ public void Validate(string schemaVersion) { DurableAgentStateSchemaVersion version = DurableAgentStateSchemaVersion.ParseSupported(schemaVersion); + if (this.IngestedPositions?.Values.Any(static position => position < 0) == true) + { + throw new InvalidOperationException( + "Durable agent ingestion positions must be non-negative."); + } + + this.Truncation?.Validate(); + if (version.Major == DurableAgentState.RevisedSchemaMajorVersion) { if (this.ConversationHistory is null) @@ -145,19 +157,42 @@ public void Validate(string schemaVersion) "A revised durable agent state requires a conversation history collection."); } - if (this.HistoryBinding is null || - this.TerminalResults is null || + if (this.TerminalResults is null || this.CompletionReceipts is null) { throw new InvalidOperationException( - "A revised durable agent state requires history binding, terminal results, and completion receipts."); + "A revised durable agent state requires terminal results and completion receipts."); + } + + this.HistoryBinding?.Validate(); + Dictionary terminalResults = + this.TerminalResults.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal); + Dictionary completionReceipts = + this.CompletionReceipts.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal); + + foreach (DurableAgentStateEntry? entry in this.ConversationHistory) + { + if (entry is null) + { + throw new InvalidOperationException( + "A revised durable agent state cannot contain null conversation entries."); + } + + entry.ValidateV2(); } - this.HistoryBinding.Validate(); foreach ((string correlationId, DurableAgentStateTerminalResult result) in this.TerminalResults) { result.Validate(correlationId); - if (!this.CompletionReceipts.TryGetValue(correlationId, out DurableAgentStateCompletionReceipt? receipt)) + if (!completionReceipts.TryGetValue( + correlationId, + out DurableAgentStateCompletionReceipt? receipt)) { throw new InvalidOperationException( $"Durable agent terminal result '{correlationId}' has no completion receipt."); @@ -176,7 +211,7 @@ this.TerminalResults is null || foreach ((string correlationId, DurableAgentStateCompletionReceipt receipt) in this.CompletionReceipts) { receipt.Validate(correlationId); - bool hasResult = this.TerminalResults.ContainsKey(correlationId); + bool hasResult = terminalResults.ContainsKey(correlationId); if (receipt.ResultState == DurableAgentStateCompletionReceipt.AvailableResult != hasResult) { throw new InvalidOperationException( @@ -189,7 +224,7 @@ this.CompletionReceipts is not null || this.HistoryBinding is not null) { throw new InvalidOperationException( - "Mailbox and fixed-history binding fields require durable agent state schema version 2.x."); + "Mailbox and provisional history-binding fields require durable agent state schema version 2.0.0."); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs index dc75542..4db9af4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs @@ -31,13 +31,18 @@ internal abstract class DurableAgentStateEntry /// Gets the timestamp when this entry was created. /// [JsonPropertyName("createdAt")] - public required DateTimeOffset CreatedAt { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } /// /// Gets the list of messages associated with this entry, in chronological order. /// [JsonPropertyName("messages")] - public IReadOnlyList Messages { get; init; } = []; + public IReadOnlyList Messages + { + get; + init => field = value ?? []; + } = []; /// /// Gets application-defined entry metadata from the schema's extensionData property. @@ -51,4 +56,33 @@ internal abstract class DurableAgentStateEntry /// [JsonExtensionData] public IDictionary? UnknownProperties { get; set; } + + public void ValidateV2() + { + if (this is DurableAgentStateCompaction) + { + if (this.CorrelationId is not null) + { + throw new InvalidOperationException( + "A durable agent compaction entry cannot have a correlation ID."); + } + } + else if (this.CorrelationId is not null) + { + DurableAgentStateContract.ValidateIdentifier( + this.CorrelationId, + "conversationHistory.correlationId"); + } + + foreach (DurableAgentStateMessage? message in this.Messages) + { + if (message is null) + { + throw new InvalidOperationException( + "A revised durable agent state cannot contain null messages."); + } + + message.ValidateV2(); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs index 833e4e5..b73a603 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs @@ -29,8 +29,12 @@ internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent /// Gets the error details. /// [JsonPropertyName("details")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Details { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Details + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } /// /// Creates a from an . @@ -43,7 +47,7 @@ public static DurableAgentStateErrorContent FromErrorContent(ErrorContent conten return new DurableAgentStateErrorContent() { Details = content.Details is null - ? null + ? default : JsonSerializer.SerializeToElement( content.Details, DurableAgentStateJsonContext.Default.String), @@ -57,11 +61,12 @@ public override AIContent ToAIContent() { return new ErrorContent(this.Message) { - Details = this.Details is JsonElement details - ? details.ValueKind == JsonValueKind.String - ? details.GetString() - : details.GetRawText() - : null, + Details = this.Details.ValueKind switch + { + JsonValueKind.Undefined => null, + JsonValueKind.String => this.Details.GetString(), + _ => this.Details.GetRawText(), + }, ErrorCode = this.ErrorCode }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs index 8b655e1..033a6d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -13,19 +12,14 @@ namespace Microsoft.Agents.AI.DurableTask.State; internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent { /// - /// The function call arguments, each encoded as JSON. + /// Gets the original function-call arguments as an object or verbatim string. /// /// - /// Arguments produced by a chat client from a model response are already - /// values, but callers can supply containing arbitrary objects (for - /// example when replaying history or resuming an approval). Those are encoded here using - /// so that persisting the state cannot fail on a type the - /// state serializer has no metadata for. + /// String form is preserved without parsing or normalization, including incomplete or non-JSON text. /// - /// TODO: Consider ensuring that empty dictionaries are omitted from serialization. [JsonPropertyName("arguments")] - public required IReadOnlyDictionary Arguments { get; init; } = - ImmutableDictionary.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Arguments { get; init; } /// /// Gets the function call identifier. @@ -52,13 +46,24 @@ internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateCo /// public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content) { - Dictionary arguments = []; - if (content.Arguments is not null) + JsonElement arguments = default; + if (content.RawRepresentation is string encodedArguments) { + arguments = JsonSerializer.SerializeToElement( + encodedArguments, + DurableAgentStateJsonContext.Default.String); + } + else if (content.Arguments is not null) + { + Dictionary argumentValues = []; foreach (KeyValuePair argument in content.Arguments) { - arguments[argument.Key] = ToJsonElement(argument.Value); + argumentValues[argument.Key] = ToJsonElement(argument.Value); } + + arguments = JsonSerializer.SerializeToElement( + argumentValues, + DurableAgentStateJsonContext.Default.DictionaryStringJsonElement); } return new DurableAgentStateFunctionCallContent() @@ -72,12 +77,38 @@ public static DurableAgentStateFunctionCallContent FromFunctionCallContent(Funct /// public override AIContent ToAIContent() { - Dictionary arguments = new(this.Arguments.Count); - foreach (KeyValuePair argument in this.Arguments) + if (this.Arguments.ValueKind == JsonValueKind.String) { - arguments[argument.Key] = argument.Value; + string encodedArguments = this.Arguments.GetString()!; + return new FunctionCallContent(this.CallId, this.Name) + { + RawRepresentation = encodedArguments, + }; + } + + Dictionary? arguments = + this.Arguments.ValueKind == JsonValueKind.Undefined ? [] : null; + if (this.Arguments.ValueKind == JsonValueKind.Object) + { + arguments = []; + foreach (JsonProperty argument in this.Arguments.EnumerateObject()) + { + arguments[argument.Name] = argument.Value.Clone(); + } } return new FunctionCallContent(this.CallId, this.Name, arguments); } + + /// + public override void ValidateV2() + { + if (this.Arguments.ValueKind is not JsonValueKind.Undefined and + not JsonValueKind.Object and + not JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent function-call arguments must be an object, a verbatim string, or absent."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs index 8c79d67..b5a1af8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs @@ -32,8 +32,12 @@ internal sealed class DurableAgentStateFunctionResultContent : DurableAgentState /// persisted under this single property. /// [JsonPropertyName("result")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Result { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Result + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } /// /// Creates a from a . @@ -48,15 +52,14 @@ public static DurableAgentStateFunctionResultContent FromFunctionResultContent(F // A null result is left absent rather than encoded as a JSON null so that it round trips // back to a null FunctionResultContent.Result. - Result = content.Result is null ? null : ToJsonElement(content.Result) + Result = content.Result is null ? default : ToJsonElement(content.Result) }; } /// public override AIContent ToAIContent() { - // Boxing a JsonElement? yields either a boxed JsonElement or null, matching the shape chat - // clients expect from a tool whose result was marshalled into JSON. - return new FunctionResultContent(this.CallId, this.Result); + object? result = this.Result.ValueKind == JsonValueKind.Undefined ? null : this.Result; + return new FunctionResultContent(this.CallId, result); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs index ff5d086..767db9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs @@ -6,8 +6,12 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// -/// Binds a durable session to one logical history owner for its lifetime. +/// Describes an optional configured history facility. /// +/// +/// This provisional shared shape is configuration metadata, not proof of the effective owner for every run. +/// Runtime-specific policy and supported ownership transitions are validated outside this DTO. +/// internal sealed class DurableAgentStateHistoryBinding { public const int CurrentVersion = 1; @@ -41,7 +45,7 @@ not HistoryProviderOwner and not ModelServiceOwner) { throw new InvalidOperationException( - $"The durable agent state history owner kind '{this.OwnerKind}' is not supported."); + $"The durable agent state configured history owner kind '{this.OwnerKind}' is not supported."); } DurableAgentStateContract.ValidateIdentifier(this.ProviderKey, "historyBinding.providerKey"); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index e60dbac..de6f25e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -22,6 +22,7 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(DurableAgentStateTerminalError))] // Function call and result content [JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(object))] [JsonSerializable(typeof(JsonDocument))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index 0949692..a446788 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace Microsoft.Agents.AI.DurableTask.State; @@ -10,6 +12,10 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// internal sealed class DurableAgentStateJsonConverter : JsonConverter { + private static readonly Regex s_rfc3339Pattern = new( + @"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$", + RegexOptions.CultureInvariant); + private const string SchemaVersionPropertyName = "schemaVersion"; private const string DataPropertyName = "data"; private const string ExtensionDataPropertyName = "extensionData"; @@ -21,6 +27,29 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter? extensionData = element.Value.TryGetProperty(ExtensionDataPropertyName, out JsonElement extensionDataElement) @@ -81,8 +127,22 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options) + { + WriteValue(writer, value, allowRevisedSchema: false); + } + + private static void WriteValue( + Utf8JsonWriter writer, + DurableAgentState value, + bool allowRevisedSchema) { _ = DurableAgentStateSchemaVersion.ParseSupported(value.SchemaVersion); + if (value.SchemaVersion == DurableAgentState.RevisedSchemaVersion && !allowRevisedSchema) + { + throw new InvalidOperationException( + "Durable agent state schema 2.0.0 requires mailbox-aware runtime activation."); + } + value.Data.Validate(value.SchemaVersion); writer.WriteStartObject(); @@ -118,11 +178,6 @@ not DataPropertyName and private static Dictionary? ReadExtensionData(JsonElement element) { - if (element.ValueKind == JsonValueKind.Null) - { - return null; - } - if (element.ValueKind != JsonValueKind.Object) { throw new JsonException("The durable agent state 'extensionData' property must be an object."); @@ -159,7 +214,6 @@ private static void ValidateRevisedLayout(JsonElement dataElement) "conversationHistory", "terminalResults", "completionReceipts", - "historyBinding", }) { if (!dataElement.TryGetProperty(requiredProperty, out _)) @@ -171,6 +225,15 @@ private static void ValidateRevisedLayout(JsonElement dataElement) ValidateUniqueObjectKeys(dataElement.GetProperty("terminalResults"), "terminalResults"); ValidateUniqueObjectKeys(dataElement.GetProperty("completionReceipts"), "completionReceipts"); + if (dataElement.TryGetProperty("historyBinding", out JsonElement historyBinding) && + historyBinding.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The revised durable agent state 'data.historyBinding' property must be an object when present."); + } + + ValidateIngestionAndTruncation(dataElement); + ValidateTranscript(dataElement.GetProperty("conversationHistory")); ValidateTerminalMessages(dataElement.GetProperty("terminalResults")); } @@ -185,6 +248,381 @@ private static void ValidateOpaqueSession(JsonElement dataElement) } } + private static void RejectLegacyRevisedFields(JsonElement dataElement) + { + if (dataElement.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (string propertyName in new[] + { + "terminalResults", + "completionReceipts", + "historyBinding", + }) + { + if (dataElement.TryGetProperty(propertyName, out _)) + { + throw new InvalidOperationException( + $"The durable agent state 'data.{propertyName}' property requires schema version 2.0.0."); + } + } + + ValidateIngestionAndTruncation(dataElement); + } + + private static void ValidateIngestionAndTruncation(JsonElement dataElement) + { + if (dataElement.TryGetProperty("ingestedPositions", out JsonElement ingestedPositions)) + { + if (ingestedPositions.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.ingestedPositions' property must be an object."); + } + + foreach (JsonProperty position in ingestedPositions.EnumerateObject()) + { + if (position.Value.ValueKind != JsonValueKind.Number || + !position.Value.TryGetInt32(out int value) || + value < 0) + { + throw new InvalidOperationException( + $"The durable agent ingestion position '{position.Name}' must be a non-negative Int32 value."); + } + } + } + + if (dataElement.TryGetProperty("truncation", out JsonElement truncation)) + { + if (truncation.ValueKind != JsonValueKind.Object || + !truncation.TryGetProperty("evictedMessageCount", out _) || + !truncation.TryGetProperty("firstEvictedAt", out _) || + !truncation.TryGetProperty("lastEvictedAt", out _)) + { + throw new InvalidOperationException( + "Durable agent truncation evidence requires evictedMessageCount, firstEvictedAt, and lastEvictedAt."); + } + } + } + + private static void ValidateDeclaredExtensionData(JsonElement root, JsonElement data) + { + RequireObjectWhenPresent(root, ExtensionDataPropertyName, "extensionData"); + RequireObjectWhenPresent(data, ExtensionDataPropertyName, "data.extensionData"); + + if (data.TryGetProperty("conversationHistory", out JsonElement history) && + history.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireObjectWhenPresent(entry, ExtensionDataPropertyName, "conversationHistory.extensionData"); + string? entryType = entry.TryGetProperty("$type", out JsonElement typeElement) && + typeElement.ValueKind == JsonValueKind.String + ? typeElement.GetString() + : null; + if (entryType is "response" or "errorResponse" && + entry.TryGetProperty("usage", out JsonElement usage) && + usage.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent(usage, ExtensionDataPropertyName, "conversationHistory.usage.extensionData"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages) && + messages.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + message, + ExtensionDataPropertyName, + "conversationHistory.messages.extensionData"); + } + } + } + } + } + + if (data.TryGetProperty("terminalResults", out JsonElement terminalResults) && + terminalResults.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (result.Value.TryGetProperty("resultExpiresAt", out JsonElement resultExpiresAt) && + resultExpiresAt.ValueKind != JsonValueKind.String) + { + throw new JsonException( + $"Durable agent terminal result '{result.Name}' resultExpiresAt must be a string when present."); + } + + if (result.Value.TryGetProperty("error", out JsonElement error) && + error.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + $"Durable agent terminal result '{result.Name}' error must be an object when present."); + } + + if (result.Value.TryGetProperty("response", out JsonElement response) && + response.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + response, + ExtensionDataPropertyName, + $"terminalResults.{result.Name}.response.extensionData"); + foreach (string propertyName in new[] + { + "createdAt", + "responseId", + "agentId", + "finishReason", + "continuationToken", + }) + { + RequireStringWhenPresent( + response, + propertyName, + $"terminalResults.{result.Name}.response.{propertyName}"); + } + + if (response.TryGetProperty("usage", out JsonElement usage) && + usage.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + usage, + ExtensionDataPropertyName, + $"terminalResults.{result.Name}.response.usage.extensionData"); + } + } + } + } + + if (data.TryGetProperty("completionReceipts", out JsonElement receipts) && + receipts.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty receipt in receipts.EnumerateObject()) + { + foreach (string propertyName in new[] { "resultExpiresAt", "resultUnavailableAt" }) + { + RequireStringWhenPresent( + receipt.Value, + propertyName, + $"completionReceipts.{receipt.Name}.{propertyName}"); + } + } + } + } + + private static void ValidateKnownFieldShapes(JsonElement data) + { + RequireDateTimeWhenPresent( + data, + "expirationTimeUtc", + "data.expirationTimeUtc", + allowNull: true); + + if (data.TryGetProperty("conversationHistory", out JsonElement history) && + history.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireDateTimeWhenPresent(entry, "createdAt", "conversationHistory.createdAt"); + RequireStringWhenPresent(entry, "correlationId", "conversationHistory.correlationId"); + string? entryType = entry.TryGetProperty("$type", out JsonElement typeElement) && + typeElement.ValueKind == JsonValueKind.String + ? typeElement.GetString() + : null; + if (entryType == "request") + { + RequireStringWhenPresent(entry, "orchestrationId", "conversationHistory.orchestrationId"); + RequireStringWhenPresent(entry, "responseType", "conversationHistory.responseType"); + RequireObjectWhenPresent(entry, "responseSchema", "conversationHistory.responseSchema"); + } + else if (entryType is "response" or "errorResponse") + { + ValidateUsageWhenPresent(entry, "usage", "conversationHistory.usage"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages) && + messages.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireStringWhenPresent(message, "authorName", "conversationHistory.messages.authorName"); + RequireDateTimeWhenPresent( + message, + "createdAt", + "conversationHistory.messages.createdAt"); + RequireStringWhenPresent(message, "messageId", "conversationHistory.messages.messageId"); + } + } + } + } + + if (data.TryGetProperty("terminalResults", out JsonElement terminalResults) && + terminalResults.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (result.Value.TryGetProperty("response", out JsonElement response) && + response.ValueKind == JsonValueKind.Object) + { + RequireDateTimeWhenPresent( + result.Value, + "completedAt", + $"terminalResults.{result.Name}.completedAt"); + RequireDateTimeWhenPresent( + result.Value, + "resultExpiresAt", + $"terminalResults.{result.Name}.resultExpiresAt"); + RequireDateTimeWhenPresent( + response, + "createdAt", + $"terminalResults.{result.Name}.response.createdAt"); + ValidateUsageWhenPresent( + response, + "usage", + $"terminalResults.{result.Name}.response.usage"); + } + } + } + + if (data.TryGetProperty("completionReceipts", out JsonElement receipts) && + receipts.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty receipt in receipts.EnumerateObject()) + { + RequireDateTimeWhenPresent( + receipt.Value, + "completedAt", + $"completionReceipts.{receipt.Name}.completedAt"); + RequireDateTimeWhenPresent( + receipt.Value, + "resultExpiresAt", + $"completionReceipts.{receipt.Name}.resultExpiresAt"); + RequireDateTimeWhenPresent( + receipt.Value, + "resultUnavailableAt", + $"completionReceipts.{receipt.Name}.resultUnavailableAt"); + } + } + + if (data.TryGetProperty("truncation", out JsonElement truncation) && + truncation.ValueKind == JsonValueKind.Object) + { + RequireDateTimeWhenPresent(truncation, "firstEvictedAt", "data.truncation.firstEvictedAt"); + RequireDateTimeWhenPresent(truncation, "lastEvictedAt", "data.truncation.lastEvictedAt"); + } + } + + private static void ValidateUsageWhenPresent(JsonElement parent, string propertyName, string path) + { + if (!parent.TryGetProperty(propertyName, out JsonElement usage)) + { + return; + } + + if (usage.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The durable agent state '{path}' property must be an object."); + } + + ValidateUsageObject(usage, path); + } + + private static void ValidateUsageObject(JsonElement usage, string path) + { + foreach (string countName in new[] { "inputTokenCount", "outputTokenCount", "totalTokenCount" }) + { + if (usage.TryGetProperty(countName, out JsonElement count) && + (count.ValueKind != JsonValueKind.Number || !count.TryGetInt64(out _))) + { + throw new JsonException( + $"The durable agent state '{path}.{countName}' property must be an Int64 value."); + } + } + + RequireObjectWhenPresent(usage, ExtensionDataPropertyName, $"{path}.extensionData"); + } + + private static void RequireObjectWhenPresent(JsonElement parent, string propertyName, string path) + { + if (parent.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The durable agent state '{path}' property must be an object."); + } + } + + private static void RequireStringWhenPresent(JsonElement parent, string propertyName, string path) + { + if (parent.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.String) + { + throw new JsonException($"The durable agent state '{path}' property must be a string."); + } + } + + private static void RequireDateTimeWhenPresent( + JsonElement parent, + string propertyName, + string path, + bool allowNull = false) + { + if (!parent.TryGetProperty(propertyName, out JsonElement value)) + { + return; + } + + if (allowNull && value.ValueKind == JsonValueKind.Null) + { + return; + } + + if (value.ValueKind != JsonValueKind.String || + !IsOffsetRfc3339(value.GetString())) + { + throw new JsonException( + $"The durable agent state '{path}' property must be an RFC 3339 date-time with an explicit offset."); + } + } + + private static bool IsOffsetRfc3339(string? value) + { + if (string.IsNullOrEmpty(value) || + !s_rfc3339Pattern.IsMatch(value) || + !DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _)) + { + return false; + } + + return value.EndsWith('Z') || + (value.Length >= 6 && + value[^6] is '+' or '-' && + value[^3] == ':'); + } + private static void ValidateUniqueObjectKeys(JsonElement element, string propertyName) { if (element.ValueKind != JsonValueKind.Object) @@ -214,27 +652,195 @@ private static void ValidateTerminalMessages(JsonElement terminalResults) $"Durable agent terminal result '{result.Name}' requires a response messages collection."); } - if (messages.ValueKind != JsonValueKind.Array) + ValidateMessageArray(messages, $"terminal result '{result.Name}'"); + } + } + + private static void ValidateTranscript(JsonElement conversationHistory) + { + if (conversationHistory.ValueKind != JsonValueKind.Array) + { + throw new JsonException( + "The revised durable agent state 'data.conversationHistory' property must be an array."); + } + + foreach (JsonElement entry in conversationHistory.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object || + !entry.TryGetProperty("$type", out JsonElement typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + continue; + } + + string? entryType = typeElement.GetString(); + bool hasCorrelation = entry.TryGetProperty("correlationId", out JsonElement correlation); + if (entryType == "compaction" && hasCorrelation) { throw new InvalidOperationException( - $"Durable agent terminal result '{result.Name}' contains a non-array response messages property."); + "A revised durable agent compaction entry cannot declare correlationId."); } - foreach (JsonElement message in messages.EnumerateArray()) + if (entryType is "request" or "response" or "errorResponse" && hasCorrelation) { - if (message.ValueKind != JsonValueKind.Object) + if (correlation.ValueKind != JsonValueKind.String) { throw new InvalidOperationException( - $"Durable agent terminal result '{result.Name}' contains a non-object message."); + "A revised durable agent transcript correlationId must be a string when present."); } - if (message.TryGetProperty("contents", out JsonElement contents) && - contents.ValueKind != JsonValueKind.Array) + DurableAgentStateContract.ValidateIdentifier( + correlation.GetString(), + "conversationHistory.correlationId"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages)) + { + ValidateMessageArray(messages, "conversationHistory"); + } + } + } + + private static void ValidateMessageArray(JsonElement messages, string location) + { + if (messages.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent {location} messages must be an array."); + } + + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Durable agent {location} contains a non-object message."); + } + + if (!message.TryGetProperty("contents", out JsonElement contents)) + { + continue; + } + + if (contents.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent {location} contains a non-array message contents property."); + } + + foreach (JsonElement content in contents.EnumerateArray()) + { + if (content.ValueKind != JsonValueKind.Object || + !content.TryGetProperty("$type", out JsonElement contentType) || + contentType.ValueKind != JsonValueKind.String) + { + continue; + } + + if (contentType.ValueEquals("functionCall") && + content.TryGetProperty("arguments", out JsonElement arguments) && + arguments.ValueKind is not JsonValueKind.Object and not JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent function-call arguments must be an object or string when present."); + } + + if (contentType.ValueEquals("uri") && + content.TryGetProperty("mediaType", out JsonElement mediaType) && + mediaType.ValueKind != JsonValueKind.String) { throw new InvalidOperationException( - $"Durable agent terminal result '{result.Name}' contains a non-array message contents property."); + "Durable agent URI mediaType must be a string when present."); + } + + if (contentType.ValueEquals("usage") && + content.TryGetProperty("usage", out JsonElement contentUsage)) + { + if (contentUsage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Durable agent usage content requires an object-valued usage property."); + } + + ValidateUsageObject(contentUsage, "message.contents.usage"); } + + ValidateKnownContentFields(content, contentType.GetString()!); } } } + + private static void ValidateKnownContentFields(JsonElement content, string contentType) + { + switch (contentType) + { + case "data": + RequireString(content, "uri", contentType); + OptionalString(content, "mediaType", contentType); + break; + case "error": + OptionalString(content, "message", contentType); + OptionalString(content, "errorCode", contentType); + break; + case "functionCall": + RequireString(content, "callId", contentType); + RequireString(content, "name", contentType); + break; + case "functionResult": + RequireString(content, "callId", contentType); + break; + case "hostedFile": + RequireString(content, "fileId", contentType); + break; + case "hostedVectorStore": + RequireString(content, "vectorStoreId", contentType); + break; + case "text": + RequireString(content, "text", contentType); + break; + case "reasoning": + OptionalString(content, "text", contentType); + break; + case "uri": + RequireString(content, "uri", contentType); + break; + case "usage": + if (!content.TryGetProperty("usage", out JsonElement usage) || + usage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Durable agent usage content requires an object-valued usage property."); + } + + break; + case "unknown": + if (!content.TryGetProperty("content", out _)) + { + throw new InvalidOperationException( + "Durable agent unknown content requires the original content value."); + } + + break; + } + } + + private static void RequireString(JsonElement element, string propertyName, string contentType) + { + if (!element.TryGetProperty(propertyName, out JsonElement value) || + value.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Durable agent '{contentType}' content requires string property '{propertyName}'."); + } + } + + private static void OptionalString(JsonElement element, string propertyName, string contentType) + { + if (element.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Durable agent '{contentType}' content property '{propertyName}' must be a string when present."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index cff6fdf..1d7e09d 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -142,4 +142,28 @@ public ChatMessage ToChatMessage() Role = new(this.Role) }; } + + public void ValidateV2() + { + if (this.Role is not "user" and + not "assistant" and + not "system" and + not "developer" and + not "tool") + { + throw new InvalidOperationException( + $"The durable agent state message role '{this.Role}' is not supported."); + } + + if (this.Contents.Any(static content => content is null)) + { + throw new InvalidOperationException( + "A durable agent state message cannot contain null content entries."); + } + + foreach (DurableAgentStateContent content in this.Contents) + { + content.ValidateV2(); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs index 2b9e03a..46e72c0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs @@ -28,7 +28,11 @@ public static void EnsureMessageIds(IEnumerable history) DurableAgentStateMessage message = entry.Messages[index]; if (message.MessageId is null) { - message.MessageId = Create(entryType, entry.CorrelationId, entry.CreatedAt, index); + message.MessageId = Create( + entryType, + entry.CorrelationId, + entry.CreatedAt ?? default, + index); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs index b124974..c664081 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs @@ -10,23 +10,25 @@ namespace Microsoft.Agents.AI.DurableTask.State; internal readonly record struct DurableAgentStateSchemaVersion(BigInteger Major, BigInteger Minor, BigInteger Patch) : IComparable { - private static readonly BigInteger[] s_supportedMajorVersions = [1, DurableAgentState.RevisedSchemaMajorVersion]; + private static readonly HashSet s_supportedVersions = + [ + "1.0.0", + "1.1.0", + DurableAgentState.CurrentSchemaVersion, + DurableAgentState.RevisedSchemaVersion, + ]; /// /// Parses and validates a supported durable agent state schema version. /// public static DurableAgentStateSchemaVersion ParseSupported(string? value) { - if (!TryParse(value, out DurableAgentStateSchemaVersion version)) - { - throw new InvalidOperationException("The durable agent state has an invalid 'schemaVersion' property."); - } - - if (!s_supportedMajorVersions.Contains(version.Major)) + if (value is null || !s_supportedVersions.Contains(value)) { throw new InvalidOperationException($"The durable agent state schema version '{value}' is not supported."); } + _ = TryParse(value, out DurableAgentStateSchemaVersion version); return version; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs index bc27003..4f50a7c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs @@ -17,8 +17,12 @@ internal sealed class DurableAgentStateTerminalError public required string Message { get; init; } [JsonPropertyName("details")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Details { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Details + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } [JsonExtensionData] public IDictionary? UnknownProperties { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs index ea5a10d..2c18bcb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs @@ -29,6 +29,21 @@ public IReadOnlyList Messages [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? CreatedAt { get; init; } + /// + /// Gets an optional caller-visible JSON result independent of the response messages. + /// + /// + /// means the wire property was absent. All other JSON values, + /// including explicit null, false, zero, empty strings, arrays, and objects, are present values. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Value + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + [JsonPropertyName("responseId")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ResponseId { get; init; } @@ -65,6 +80,7 @@ public static DurableAgentStateTerminalResponse FromResponse( AgentResponse response, string correlationId, DateTimeOffset completedAt, + JsonElement structuredValue = default, ILogger? logger = null) { Dictionary? additionalProperties = null; @@ -87,6 +103,7 @@ public static DurableAgentStateTerminalResponse FromResponse( .ToList(), Usage = DurableAgentStateUsage.FromUsage(response.Usage), CreatedAt = response.CreatedAt, + Value = structuredValue, ResponseId = response.ResponseId, AgentId = response.AgentId, FinishReason = response.FinishReason?.Value, @@ -135,17 +152,7 @@ public void Validate() "A durable agent terminal response cannot contain null messages or content collections."); } - if (message.Role is not "user" and not "assistant" and not "system" and not "tool") - { - throw new InvalidOperationException( - $"The durable agent terminal response message role '{message.Role}' is not supported."); - } - - if (message.Contents.Any(static content => content is null)) - { - throw new InvalidOperationException( - "A durable agent terminal response cannot contain null content entries."); - } + message.ValidateV2(); } ValidateOptionalIdentifier(this.ResponseId, "terminalResults.response.responseId"); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs index dbbd4ef..58dce17 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs @@ -9,6 +9,11 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// /// Immutable terminal result envelope detached from evictable conversation history. /// +/// +/// A later runtime layer must commit this result and its matching receipt in the same durable entity +/// operation as session continuation, ingestion bookkeeping, entity-local transcript, TTL, optional +/// binding, and other local control state. This DTO performs no commit or delivery behavior. +/// internal sealed class DurableAgentStateTerminalResult { [JsonPropertyName("correlationId")] @@ -40,6 +45,7 @@ public static DurableAgentStateTerminalResult FromResponse( AgentResponse response, DateTimeOffset completedAt, DateTimeOffset? resultExpiresAt = null, + JsonElement structuredValue = default, ILogger? logger = null) { DurableAgentStateContract.ValidateIdentifier(correlationId, "terminalResults.correlationId"); @@ -53,6 +59,7 @@ public static DurableAgentStateTerminalResult FromResponse( response, correlationId, completedAt, + structuredValue, logger), }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs index 5bf46bc..2769ae5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Agents.AI.DurableTask.State; @@ -30,4 +31,22 @@ internal sealed class DurableAgentStateTruncation /// [JsonPropertyName("lastEvictedAt")] public DateTimeOffset LastEvictedAt { get; set; } + + /// + /// Gets undeclared future truncation evidence fields. + /// + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate() + { + if (this.EvictedMessageCount < 1 || + this.FirstEvictedAt == default || + this.LastEvictedAt == default || + this.LastEvictedAt < this.FirstEvictedAt) + { + throw new InvalidOperationException( + "Durable agent truncation evidence requires a positive count and ordered first/latest timestamps."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs index 8c6bbb8..3090212 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs @@ -20,7 +20,8 @@ internal sealed class DurableAgentStateUriContent : DurableAgentStateContent /// Gets the media type of the content. /// [JsonPropertyName("mediaType")] - public required string MediaType { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MediaType { get; init; } /// /// Creates a from a . @@ -39,6 +40,12 @@ public static DurableAgentStateUriContent FromUriContent(UriContent uriContent) /// public override AIContent ToAIContent() { + if (this.MediaType is null) + { + throw new InvalidOperationException( + "The current .NET UriContent contract cannot represent a URI without a media type."); + } + return new UriContent(this.Uri, this.MediaType); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 4adcd4a..1354664 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -25,8 +25,9 @@ Some versioning considerations: Schema version 1.2 adds optional message identity and extension metadata, opaque session state, workflow `ingestedPositions`, and bounded truncation evidence. The .NET workflow path preserves but does not currently populate `ingestedPositions`. Older 1.x state remains readable. `DurableAgentState.Clone()` promotes older -supported versions to 1.2 when a caller uses that write-clone path, while later same-major versions remain -unchanged. Wiring that path into entity execution is deferred. Major versions remain fail-closed. New +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 `DurableAgentState` instances default to the current version, while deserialization preserves the persisted version through an init-only property. @@ -47,22 +48,30 @@ replay filtering, compaction, retention, and provider behavior is deferred to la ## Revised execution-state foundation -The mailbox and fixed-history binding contracts use schema `2.0.0`. This is intentionally a fail-closed major +The mailbox and provisional history-binding contracts use schema `2.0.0`. This is intentionally a fail-closed major version: a 1.x worker preserves unknown fields but does not understand completion receipts, so allowing it to -process revised state could rerun work whose transcript result was already removed. The .NET reader accepts -legacy 1.x state and revised 2.x state, but new state continues to default to `1.2.0`; this schema-only layer -does not activate revised writes. A later execution layer must opt into `2.0.0` only when it writes the complete -mailbox and binding layout. +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. In revised state, `terminalResults` stores immutable result envelopes by correlation ID outside `conversationHistory`, while `completionReceipts` retains completion evidence after a result payload expires. -No receipt means pending; an `available` receipt requires a matching result; an `unavailable` receipt proves -completion without a result payload. `historyBinding` records a versioned owner kind and stable logical provider -key. The key is explicit wire identity and must not be inferred from CLR type names or opaque session keys. +An `available` receipt requires a matching result; an `unavailable` receipt proves completion without a result +payload while retaining its outcome. Absence of a receipt means only that no terminal completion is recorded; +it does not distinguish an accepted pending request from an unknown identity. Optional `historyBinding` records +a provisional configured facility kind and stable logical provider key. It does not establish effective +per-run ownership, and the key must not be inferred from CLR type names or opaque session keys. 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. +When those layers activate schema 2.0, one successful durable entity operation must atomically commit 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. + 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 diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index 30ca1da..9acb5fd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -17,6 +17,12 @@ + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs index ea117f9..39d7df8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs @@ -110,5 +110,26 @@ public void PreviouslyPersistedArgumentsAreStillReadable() Assert.Equal(3, Assert.IsType(result.Arguments["days"]).GetInt32()); } + [Fact] + public void StringArgumentsRoundTripVerbatimWithoutParsing() + { + const string Json = + """{"$type":"functionCall","arguments":" { \"partial\": ","callId":"call-7","name":"incomplete"}"""; + + DurableAgentStateContent? deserialized = + (DurableAgentStateContent?)JsonSerializer.Deserialize(Json, s_stateContentTypeInfo); + DurableAgentStateFunctionCallContent durable = + Assert.IsType(deserialized); + string roundTrip = JsonSerializer.Serialize(durable, s_stateContentTypeInfo); + using JsonDocument roundTripDocument = JsonDocument.Parse(roundTrip); + FunctionCallContent runtime = Assert.IsType(durable.ToAIContent()); + + Assert.Equal(" { \"partial\": ", durable.Arguments.GetString()); + Assert.Equal(" { \"partial\": ", runtime.RawRepresentation); + Assert.Equal( + " { \"partial\": ", + roundTripDocument.RootElement.GetProperty("arguments").GetString()); + } + private sealed record Location(string City, string State); } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs index 0cee786..b973f66 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs @@ -30,6 +30,31 @@ public void LegacyStateRoundTripsWithoutRevisedFields() Assert.DoesNotContain("\"historyBinding\"", roundTrip, StringComparison.Ordinal); } + [Fact] + public void ProductionConverterRejectsRevisedStateUntilMailboxActivation() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + DurableAgentState state = Deserialize(Json); + + Assert.Throws( + () => JsonSerializer.Deserialize( + Json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + Assert.Throws( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + [Fact] public void RevisedFixtureRoundTripsTypedMailboxAndFutureFields() { @@ -51,16 +76,11 @@ public void RevisedFixtureRoundTripsTypedMailboxAndFutureFields() Assert.Contains("\"futureReceiptField\":7", roundTrip, StringComparison.Ordinal); Assert.Contains("\"futureBindingField\":\"preserve\"", roundTrip, StringComparison.Ordinal); Assert.Contains("\"futureRootField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); - - DurableAgentState clone = state.Clone(); - Assert.Equal(DurableAgentState.RevisedSchemaVersion, clone.SchemaVersion); - Assert.Equal("response-id-2", clone.Data.TerminalResults?["corr-2"].Response?.ResponseId); } [Theory] [InlineData("terminalResults")] [InlineData("completionReceipts")] - [InlineData("historyBinding")] public void RevisedStateRequiresCompleteLayout(string missingProperty) { Dictionary data = new() @@ -82,7 +102,130 @@ public void RevisedStateRequiresCompleteLayout(string missingProperty) data, }); - Assert.Throws(() => Deserialize(json)); + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void RevisedStateAllowsOmittedHistoryBinding() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Null(state.Data.HistoryBinding); + } + + [Theory] + [InlineData("request", "")] + [InlineData("response", " ")] + [InlineData("errorResponse", "id\u0001")] + public void RevisedTranscriptRejectsInvalidPresentCorrelation(string entryType, string correlationId) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "{{entryType}}", + "correlationId": {{JsonSerializer.Serialize(correlationId)}} + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void RevisedTranscriptAllowsMissingCorrelationButCompactionForbidsIt() + { + const string MissingCorrelation = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ "$type": "request" }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + const string CompactionCorrelation = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "compaction", + "correlationId": "not-allowed" + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.IsType( + Assert.Single(Deserialize(MissingCorrelation).Data.ConversationHistory)); + Assert.Throws(() => Deserialize(CompactionCorrelation)); + } + + [Fact] + public void TranscriptEntryPreservesAbsentCreatedAt() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ "$type": "request" }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement entry = document.RootElement.GetProperty("data").GetProperty("conversationHistory")[0]; + + Assert.False(entry.TryGetProperty("createdAt", out _)); + } + + [Fact] + public void EntryPreservesFieldsOwnedByAnotherVariantAsUnknown() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "usage": { + "extensionData": null + } + }, { + "$type": "response", + "responseSchema": "opaque" + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"usage\":{\"extensionData\":null}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"responseSchema\":\"opaque\"", roundTrip, StringComparison.Ordinal); } [Theory] @@ -136,7 +279,7 @@ public void RevisedStateRejectsNullRequiredCollections(string collection) } """; - Assert.Throws(() => Deserialize(json)); + Assert.ThrowsAny(() => Deserialize(json)); } [Fact] @@ -333,6 +476,36 @@ public void FailedTerminalResultWithMatchingReceiptIsValid() state.Data.TerminalResults?["failed"].Error?.Code); } + [Fact] + public void ExplicitResultRemovalMayPrecedeScheduledExpiry() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "removed": { + "correlationId": "removed", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "resultState": "unavailable", + "resultExpiresAt": "2026-09-12T10:00:00Z", + "resultUnavailableAt": "2026-09-11T11:00:00Z" + } + } + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal( + DateTimeOffset.Parse("2026-09-11T11:00:00Z"), + state.Data.CompletionReceipts?["removed"].ResultUnavailableAt); + } + [Fact] public void TerminalErrorLengthCountsUnicodeScalars() { @@ -469,6 +642,52 @@ public void TerminalResponsePreservesConsumerFieldsWithoutRuntimeObjects() }); } + [Theory] + [InlineData("null", JsonValueKind.Null)] + [InlineData("false", JsonValueKind.False)] + [InlineData("0", JsonValueKind.Number)] + [InlineData("\"\"", JsonValueKind.String)] + [InlineData("[]", JsonValueKind.Array)] + [InlineData("{}", JsonValueKind.Object)] + public void TerminalResponsePreservesPresentStructuredValue(string valueJson, JsonValueKind expectedKind) + { + using JsonDocument valueDocument = JsonDocument.Parse(valueJson); + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse(), + DateTimeOffset.Parse("2026-09-11T10:00:00+00:00"), + structuredValue: valueDocument.RootElement); + + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult); + DurableAgentStateTerminalResult restored = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult)); + + Assert.Contains("\"value\":", json, StringComparison.Ordinal); + Assert.Equal(expectedKind, Assert.IsType(restored.Response).Value.ValueKind); + } + + [Fact] + public void TerminalResponsePreservesAbsentStructuredValue() + { + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse(), + DateTimeOffset.Parse("2026-09-11T10:00:00+00:00")); + + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult); + + Assert.DoesNotContain("\"value\"", json, StringComparison.Ordinal); + Assert.Equal( + JsonValueKind.Undefined, + Assert.IsType(stored.Response).Value.ValueKind); + } + [Fact] public void TerminalResponseRejectsArbitraryRuntimeMetadata() { @@ -596,6 +815,290 @@ public void VersionOneStateCannotWriteRevisedFields() Assert.Throws(() => Serialize(state)); } + [Theory] + [InlineData("terminalResults")] + [InlineData("completionReceipts")] + [InlineData("historyBinding")] + public void LegacyStateRejectsPresentNullRevisedFields(string propertyName) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "{{propertyName}}": null + } + } + """; + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void RevisedStateRejectsNullHistoryBindingWhenPresent() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": null + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Theory] + [InlineData("""{"schemaVersion":"1.2.0","extensionData":null,"data":{"conversationHistory":[]}}""")] + [InlineData("""{"schemaVersion":"1.2.0","data":{"conversationHistory":[],"extensionData":null}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultExpiresAt":null,"response":{"messages":[]}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"extensionData":null}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[{"$type":"request","responseSchema":null}],"terminalResults":{},"completionReceipts":{}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[{"$type":"request","messages":[{"role":"user","createdAt":null}]}],"terminalResults":{},"completionReceipts":{}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"usage":null}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"usage":{"inputTokenCount":null}}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + public void ExplicitNullKnownFieldsAreRejected(string json) + { + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalErrorDetailsPreservesAbsentAndExplicitNull() + { + const string ExplicitNull = """ + { + "code": "Example", + "message": "failed", + "details": null + } + """; + + DurableAgentStateTerminalError present = Assert.IsType( + JsonSerializer.Deserialize( + ExplicitNull, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError)); + DurableAgentStateTerminalError absent = new() + { + Code = "Example", + Message = "failed", + }; + string presentJson = JsonSerializer.Serialize( + present, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError); + string absentJson = JsonSerializer.Serialize( + absent, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError); + + Assert.Equal(JsonValueKind.Null, present.Details.ValueKind); + Assert.Contains("\"details\":null", presentJson, StringComparison.Ordinal); + Assert.DoesNotContain("\"details\"", absentJson, StringComparison.Ordinal); + } + + [Fact] + public void IngestionPositionsMustBeNonNegative() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "ingestedPositions": { + "producer": -1 + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Fact] + public void TruncationRequiresCompleteValidEvidence() + { + const string MissingFields = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": {} + } + } + """; + DurableAgentState invalidState = new() + { + Data = new() + { + Truncation = new() + { + EvictedMessageCount = 1, + FirstEvictedAt = DateTimeOffset.Parse("2026-09-11T11:00:00+00:00"), + LastEvictedAt = DateTimeOffset.Parse("2026-09-11T10:00:00+00:00"), + }, + }, + }; + + Assert.Throws(() => Deserialize(MissingFields)); + Assert.Throws(() => Serialize(invalidState)); + } + + [Fact] + public void TruncationUnknownEvidenceRoundTrips() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": { + "evictedMessageCount": 2, + "firstEvictedAt": "2026-09-11T10:00:00Z", + "lastEvictedAt": "2026-09-11T11:00:00Z", + "futureEvidence": 42 + } + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"futureEvidence\":42", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void MailboxCrossMapComparisonIsAlwaysOrdinal() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-11T10:00:00Z"); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new() + { + TerminalResults = new Dictionary( + StringComparer.OrdinalIgnoreCase) + { + ["Case-ID"] = new() + { + CorrelationId = "Case-ID", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + Response = new(), + }, + }, + CompletionReceipts = new Dictionary( + StringComparer.OrdinalIgnoreCase) + { + ["case-id"] = new() + { + CorrelationId = "case-id", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }, + }, + }, + }; + + Assert.Throws(() => Serialize(state)); + } + + [Theory] + [InlineData( + "\"completedAt\": \"2026-09-10T05:00:03+00:00\"", + "\"completedAt\": \"2026-09-10T05:00:03\"")] + [InlineData( + "\"resultExpiresAt\": \"2026-09-11T05:00:03+00:00\"", + "\"resultExpiresAt\": \"2026-09-11T05:00:03\"")] + [InlineData( + "\"resultUnavailableAt\": \"2026-09-10T05:00:04+00:00\"", + "\"resultUnavailableAt\": \"2026-09-10T05:00:04\"")] + public void RevisedMailboxRequiresOffsetBearingRfc3339Timestamps( + string original, + string invalid) + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace(original, invalid, StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void UnavailableReceiptIsTimestampValidatedWithoutTerminalResults() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-11T11:00:00" + } + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Fact] + public void LegacyTruncationRequiresOffsetAndSeconds() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": { + "evictedMessageCount": 1, + "firstEvictedAt": "2026-09-11T10:00Z", + "lastEvictedAt": "2026-09-11T11:00:00Z" + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Theory] + [InlineData("""{"$type":"text","text":null}""")] + [InlineData("""{"$type":"functionCall","callId":"c","name":null}""")] + [InlineData("""{"$type":"uri","uri":"https://example.test","mediaType":null}""")] + [InlineData("""{"$type":"usage","usage":{"inputTokenCount":null}}""")] + [InlineData("""{"$type":"usage","usage":{"extensionData":null}}""")] + public void RevisedStateRejectsMalformedKnownContent(string contentJson) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [{{contentJson}}] + }] + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + [Fact] public void IdentifierLengthCountsUnicodeScalars() { @@ -612,6 +1115,135 @@ public void IdentifierLengthCountsUnicodeScalars() Assert.Equal(providerKey, restored.Data.HistoryBinding?.ProviderKey); } + [Fact] + public void LosslessFixturePreservesDeveloperRoleArgumentsUriOpaqueContentAndValue() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0-lossless.json")); + + DurableAgentState state = Deserialize(json); + string roundTrip = Serialize(state); + DurableAgentStateRequest request = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + Assert.Equal("developer", Assert.Single(request.Messages).Role); + + DurableAgentStateTerminalResponse response = Assert.IsType( + state.Data.TerminalResults?["corr-lossless"].Response); + DurableAgentStateMessage message = Assert.Single(response.Messages); + DurableAgentStateFunctionCallContent functionCall = + Assert.IsType(message.Contents[0]); + DurableAgentStateUriContent uri = Assert.IsType(message.Contents[1]); + DurableAgentStateUnknownContent unknown = + Assert.IsType(message.Contents[2]); + + Assert.Equal(" { \"partial\": ", functionCall.Arguments.GetString()); + Assert.Null(uri.MediaType); + Assert.Equal("opaque-data-only", unknown.Content.GetProperty("$runtimeType").GetString()); + Assert.Equal(JsonValueKind.False, response.Value.ValueKind); + FunctionCallContent runtimeFunctionCall = + Assert.IsType(functionCall.ToAIContent()); + Assert.Equal(" { \"partial\": ", runtimeFunctionCall.RawRepresentation); + Assert.Throws(() => uri.ToAIContent()); + using JsonDocument roundTripDocument = JsonDocument.Parse(roundTrip); + Assert.Equal( + " { \"partial\": ", + roundTripDocument.RootElement.GetProperty("data") + .GetProperty("terminalResults") + .GetProperty("corr-lossless") + .GetProperty("response") + .GetProperty("messages")[0] + .GetProperty("contents")[0] + .GetProperty("arguments") + .GetString()); + Assert.DoesNotContain("\"mediaType\"", JsonSerializer.Serialize( + uri, + DurableAgentStateJsonContext.Default.DurableAgentStateUriContent), StringComparison.Ordinal); + } + + [Fact] + public void PrunedFixturePreservesExpiredOutcomeOpaqueSessionAndHighestSeenPosition() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0-pruned.json")); + + DurableAgentState state = Deserialize(json); + DurableAgentStateCompletionReceipt receipt = + Assert.IsType(state.Data.CompletionReceipts?["corr-pruned"]); + + Assert.Equal(DurableAgentStateCompletionReceipt.SucceededOutcome, receipt.Outcome); + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, receipt.ResultState); + Assert.False(state.Data.TerminalResults?.ContainsKey("corr-pruned")); + Assert.Equal(3, state.Data.IngestedPositions?["example-producer"]); + Assert.Equal( + "opaque-user-data", + state.Data.Session?.GetProperty("exampleContinuation").GetProperty("$runtimeType").GetString()); + Assert.Equal(4, state.Data.Truncation?.EvictedMessageCount); + } + + [Theory] + [InlineData("null", JsonValueKind.Null)] + [InlineData("\"verbatim\"", JsonValueKind.String)] + [InlineData("[0,false,null]", JsonValueKind.Array)] + public void ExplicitOpaqueJsonContentRoundTripsLosslessly(string contentJson, JsonValueKind expectedKind) + { + string json = $$""" + { + "$type": "unknown", + "content": {{contentJson}} + } + """; + + DurableAgentStateUnknownContent content = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateContent)); + string roundTrip = JsonSerializer.Serialize( + content, + DurableAgentStateJsonContext.Default.DurableAgentStateUnknownContent); + + using JsonDocument document = JsonDocument.Parse(roundTrip); + Assert.Equal(expectedKind, content.Content.ValueKind); + Assert.True(JsonElement.DeepEquals( + JsonDocument.Parse(contentJson).RootElement, + document.RootElement.GetProperty("content"))); + } + + [Fact] + public void KnownContentPreservesExplicitNullVersusAbsent() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [ + { "$type": "error", "details": null }, + { "$type": "functionResult", "callId": "null", "result": null }, + { "$type": "functionResult", "callId": "absent" } + ] + }] + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement contents = document.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[0] + .GetProperty("messages")[0] + .GetProperty("contents"); + + Assert.Equal(JsonValueKind.Null, contents[0].GetProperty("details").ValueKind); + Assert.Equal(JsonValueKind.Null, contents[1].GetProperty("result").ValueKind); + Assert.False(contents[2].TryGetProperty("result", out _)); + } + private static DurableAgentState CreateEmptyRevisedState(DurableAgentStateHistoryBinding binding) { return new() @@ -671,10 +1303,17 @@ private static string CreateRevisedJson( """; } - private static DurableAgentState Deserialize(string json) => - Assert.IsType( - JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + private static DurableAgentState Deserialize(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("schemaVersion").GetString() == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateJsonConverter.DeserializeRevisedContract(json) + : Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + } private static string Serialize(DurableAgentState state) => - JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + state.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateJsonConverter.SerializeRevisedContract(state) + : JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs index b92c825..d6ffe25 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -35,14 +35,9 @@ public void InvalidVersion() [Theory] [InlineData("1.0.0")] - [InlineData("1.1.9")] + [InlineData("1.1.0")] [InlineData("1.2.0")] - [InlineData("1.2.7")] - [InlineData("1.3.0")] - [InlineData("1.9.2")] - [InlineData("1.2147483648.0")] - [InlineData("1.2.2147483648")] - public void StrictNumericSemVerIsAccepted(string version) + public void DeclaredSchemaVersionsAreAccepted(string version) { string json = $$""" { @@ -74,7 +69,15 @@ public void StrictNumericSemVerIsAccepted(string version) [InlineData("1.2.0-alpha")] [InlineData("1.2.0+build")] [InlineData("1.2.0-alpha+build")] - public void InvalidSchemaVersionGrammarIsRejected(string version) + [InlineData("1.0.7")] + [InlineData("1.1.9")] + [InlineData("1.2.7")] + [InlineData("1.3.0")] + [InlineData("2.0.1")] + [InlineData("2.1.0")] + [InlineData("1.2147483648.0")] + [InlineData("1.2.2147483648")] + public void InvalidOrUndeclaredSchemaVersionIsRejected(string version) { string json = $$""" { @@ -402,9 +405,7 @@ public void BasicState() [Theory] [InlineData("1.0.0")] - [InlineData("1.0.7")] [InlineData("1.1.0")] - [InlineData("1.1.9")] public void CloneForWritePromotesOlderCompatibleStateToCurrentVersion(string version) { string json = $$""" @@ -429,16 +430,13 @@ public void CloneForWritePromotesOlderCompatibleStateToCurrentVersion(string ver Assert.Contains("\"schemaVersion\":\"1.2.0\"", roundTrip, StringComparison.Ordinal); } - [Theory] - [InlineData("1.2.0")] - [InlineData("1.2.7")] - [InlineData("1.3.0")] - [InlineData("1.9.2")] - public void CloneForWritePreservesCurrentAndFutureCompatibleVersions(string version) + [Fact] + public void CloneForWritePreservesCurrentVersion() { + const string Version = "1.2.0"; string json = $$""" { - "schemaVersion": "{{version}}", + "schemaVersion": "{{Version}}", "data": { "conversationHistory": [] } @@ -452,16 +450,16 @@ public void CloneForWritePreservesCurrentAndFutureCompatibleVersions(string vers clone, DurableAgentStateJsonContext.Default.DurableAgentState); - Assert.Equal(version, clone.SchemaVersion); - Assert.Contains($"\"schemaVersion\":\"{version}\"", roundTrip, StringComparison.Ordinal); + Assert.Equal(Version, clone.SchemaVersion); + Assert.Contains($"\"schemaVersion\":\"{Version}\"", roundTrip, StringComparison.Ordinal); } [Fact] - public void FutureCompatibleVersionAndUnknownFieldsSurviveMutationAndRoundTrip() + public void CurrentVersionUnknownFieldsSurviveMutationAndRoundTrip() { const string JsonText = """ { - "schemaVersion": "1.3.0", + "schemaVersion": "1.2.0", "data": { "conversationHistory": [ { @@ -493,7 +491,7 @@ public void FutureCompatibleVersionAndUnknownFieldsSurviveMutationAndRoundTrip() DurableAgentStateJsonContext.Default.DurableAgentState); using JsonDocument document = JsonDocument.Parse(roundTrip); - Assert.Equal("1.3.0", document.RootElement.GetProperty("schemaVersion").GetString()); + Assert.Equal("1.2.0", document.RootElement.GetProperty("schemaVersion").GetString()); Assert.Equal(42, document.RootElement.GetProperty("futureRoot").GetInt32()); JsonElement data = document.RootElement.GetProperty("data"); Assert.Equal("preserve", data.GetProperty("futureData").GetString()); From de5b77486faa1f1ed0f5bf19267646ec43f08400 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Sat, 12 Sep 2026 03:52:21 +0300 Subject: [PATCH 5/6] Align .NET state readers with versioned schema Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 532fe4f5-939b-4962-989d-a1883dccd283 --- .../State/DurableAgentStateContent.cs | 14 +- .../State/DurableAgentStateData.cs | 21 +- .../DurableAgentStateFunctionCallContent.cs | 7 +- .../State/DurableAgentStateHistoryBinding.cs | 53 ---- .../State/DurableAgentStateJsonContext.cs | 1 - .../State/DurableAgentStateJsonConverter.cs | 119 +++++++- .../State/DurableAgentStateMessage.cs | 14 +- .../State/README.md | 7 +- ...oft.Agents.AI.DurableTask.UnitTests.csproj | 3 + ...rableAgentStateFunctionCallContentTests.cs | 19 ++ .../State/DurableAgentStateMailboxTests.cs | 269 +++++++++++++++--- .../State/DurableAgentStateMessageTests.cs | 13 + .../State/DurableAgentStateTests.cs | 4 +- 13 files changed, 428 insertions(+), 116 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs index 76aac72..ea79b9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -68,12 +68,24 @@ public virtual void ValidateV2() /// The logger used to report safe unknown-content fallbacks. /// A representing the original . public static DurableAgentStateContent FromAIContent(AIContent content, ILogger? logger = null) + => FromAIContent(content, allowLosslessV2: false, logger); + + internal static DurableAgentStateContent FromAIContentV2(AIContent content, ILogger? logger = null) + => FromAIContent(content, allowLosslessV2: true, logger); + + private static DurableAgentStateContent FromAIContent( + AIContent content, + bool allowLosslessV2, + ILogger? logger) { return content switch { DataContent dataContent => DurableAgentStateDataContent.FromDataContent(dataContent), ErrorContent errorContent => DurableAgentStateErrorContent.FromErrorContent(errorContent), - FunctionCallContent functionCallContent => DurableAgentStateFunctionCallContent.FromFunctionCallContent(functionCallContent), + FunctionCallContent functionCallContent => + DurableAgentStateFunctionCallContent.FromFunctionCallContent( + functionCallContent, + allowLosslessV2), FunctionResultContent functionResultContent => DurableAgentStateFunctionResultContent.FromFunctionResultContent(functionResultContent), HostedFileContent hostedFileContent => DurableAgentStateHostedFileContent.FromHostedFileContent(hostedFileContent), HostedVectorStoreContent hostedVectorStoreContent => DurableAgentStateHostedVectorStoreContent.FromHostedVectorStoreContent(hostedVectorStoreContent), diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index a2e9991..7967f53 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -32,15 +32,23 @@ internal sealed class DurableAgentStateData public IDictionary? CompletionReceipts { get; init; } /// - /// Gets an optional, provisional descriptor for the configured history facility. + /// Gets an optional, separately versioned runtime history profile. /// /// - /// This shared DTO does not establish effective per-run ownership or prohibit ownership transitions. - /// A C# hosting profile may apply stricter policy in a later layer. + /// The shared contract treats this object as opaque. This layer preserves its complete JSON shape + /// without interpreting owner fields, inferring defaults, or constraining per-run ownership transitions. + /// A relying C# profile may apply stricter validation in a later layer. /// [JsonPropertyName("historyBinding")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DurableAgentStateHistoryBinding? HistoryBinding { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement HistoryBinding + { + get; + init + { + field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + } /// /// Gets or sets the opaque state produced by the configured agent's session serialization contract. @@ -164,7 +172,6 @@ public void Validate(string schemaVersion) "A revised durable agent state requires terminal results and completion receipts."); } - this.HistoryBinding?.Validate(); Dictionary terminalResults = this.TerminalResults.ToDictionary( pair => pair.Key, @@ -221,7 +228,7 @@ public void Validate(string schemaVersion) } else if (this.TerminalResults is not null || this.CompletionReceipts is not null || - this.HistoryBinding is not null) + this.HistoryBinding.ValueKind != JsonValueKind.Undefined) { throw new InvalidOperationException( "Mailbox and provisional history-binding fields require durable agent state schema version 2.0.0."); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs index 033a6d8..5ade09c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs @@ -41,13 +41,16 @@ internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateCo /// Creates a from a . /// /// The to convert. + /// Whether v2-only verbatim string arguments may be persisted. /// /// A representing the original content. /// - public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content) + public static DurableAgentStateFunctionCallContent FromFunctionCallContent( + FunctionCallContent content, + bool allowLosslessV2 = false) { JsonElement arguments = default; - if (content.RawRepresentation is string encodedArguments) + if (allowLosslessV2 && content.RawRepresentation is string encodedArguments) { arguments = JsonSerializer.SerializeToElement( encodedArguments, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs deleted file mode 100644 index 767db9c..0000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHistoryBinding.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.DurableTask.State; - -/// -/// Describes an optional configured history facility. -/// -/// -/// This provisional shared shape is configuration metadata, not proof of the effective owner for every run. -/// Runtime-specific policy and supported ownership transitions are validated outside this DTO. -/// -internal sealed class DurableAgentStateHistoryBinding -{ - public const int CurrentVersion = 1; - public const string DurableStateOwner = "durableState"; - public const string HistoryProviderOwner = "historyProvider"; - public const string ModelServiceOwner = "modelService"; - - [JsonPropertyName("version")] - [JsonRequired] - public int Version { get; init; } = CurrentVersion; - - [JsonPropertyName("ownerKind")] - public required string OwnerKind { get; init; } - - [JsonPropertyName("providerKey")] - public required string ProviderKey { get; init; } - - [JsonExtensionData] - public IDictionary? UnknownProperties { get; set; } - - public void Validate() - { - if (this.Version != CurrentVersion) - { - throw new InvalidOperationException( - $"The durable agent state history binding version '{this.Version}' is not supported."); - } - - if (this.OwnerKind is not DurableStateOwner and - not HistoryProviderOwner and - not ModelServiceOwner) - { - throw new InvalidOperationException( - $"The durable agent state configured history owner kind '{this.OwnerKind}' is not supported."); - } - - DurableAgentStateContract.ValidateIdentifier(this.ProviderKey, "historyBinding.providerKey"); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index de6f25e..edfb2ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -15,7 +15,6 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(DurableAgentStateCompaction))] [JsonSerializable(typeof(DurableAgentStateMessage))] [JsonSerializable(typeof(DurableAgentStateTruncation))] -[JsonSerializable(typeof(DurableAgentStateHistoryBinding))] [JsonSerializable(typeof(DurableAgentStateCompletionReceipt))] [JsonSerializable(typeof(DurableAgentStateTerminalResult))] [JsonSerializable(typeof(DurableAgentStateTerminalResponse))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index a446788..c13e634 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -93,6 +93,7 @@ internal static string SerializeRevisedContract(DurableAgentState state) else { RejectLegacyRevisedFields(dataElement); + ValidateLegacyTranscript(dataElement); } DurableAgentStateData? data = dataElement.Deserialize( @@ -225,13 +226,6 @@ private static void ValidateRevisedLayout(JsonElement dataElement) ValidateUniqueObjectKeys(dataElement.GetProperty("terminalResults"), "terminalResults"); ValidateUniqueObjectKeys(dataElement.GetProperty("completionReceipts"), "completionReceipts"); - if (dataElement.TryGetProperty("historyBinding", out JsonElement historyBinding) && - historyBinding.ValueKind != JsonValueKind.Object) - { - throw new JsonException( - "The revised durable agent state 'data.historyBinding' property must be an object when present."); - } - ValidateIngestionAndTruncation(dataElement); ValidateTranscript(dataElement.GetProperty("conversationHistory")); ValidateTerminalMessages(dataElement.GetProperty("terminalResults")); @@ -272,6 +266,112 @@ private static void RejectLegacyRevisedFields(JsonElement dataElement) ValidateIngestionAndTruncation(dataElement); } + private static void ValidateLegacyTranscript(JsonElement dataElement) + { + if (!dataElement.TryGetProperty("conversationHistory", out JsonElement history)) + { + return; + } + + if (history.ValueKind != JsonValueKind.Array) + { + throw new JsonException( + "The legacy durable agent state 'data.conversationHistory' property must be an array."); + } + + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent conversation history cannot contain non-object entries."); + } + + if (!entry.TryGetProperty("messages", out JsonElement messages)) + { + continue; + } + + if (messages.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "Legacy durable agent entry messages must be an array when present."); + } + + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent entry messages cannot contain non-object values."); + } + + string? roleText = + message.TryGetProperty("role", out JsonElement role) && + role.ValueKind == JsonValueKind.String + ? role.GetString() + : null; + if (roleText is not ("user" or "assistant" or "system" or "tool")) + { + throw new InvalidOperationException( + $"The legacy durable agent state message role '{roleText}' is not supported."); + } + + if (!message.TryGetProperty("contents", out JsonElement contents)) + { + continue; + } + + if (contents.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "Legacy durable agent message contents must be an array when present."); + } + + foreach (JsonElement content in contents.EnumerateArray()) + { + if (content.ValueKind != JsonValueKind.Object || + !content.TryGetProperty("$type", out JsonElement contentType) || + contentType.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "Legacy durable agent message contents require object values with string discriminators."); + } + + if (contentType.ValueEquals("functionCall") && + content.TryGetProperty("arguments", out JsonElement arguments) && + arguments.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent function-call arguments must be an object when present."); + } + + if (contentType.ValueEquals("uri") && + (!content.TryGetProperty("mediaType", out JsonElement mediaType) || + mediaType.ValueKind != JsonValueKind.String)) + { + throw new InvalidOperationException( + "Legacy durable agent URI content requires a string mediaType."); + } + + if (contentType.ValueEquals("usage") && + content.TryGetProperty("usage", out JsonElement contentUsage)) + { + if (contentUsage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent usage content requires an object-valued usage property."); + } + + ValidateUsageObject(contentUsage, "message.contents.usage"); + } + + ValidateKnownContentFields(content, contentType.GetString()!); + } + } + } + } + private static void ValidateIngestionAndTruncation(JsonElement dataElement) { if (dataElement.TryGetProperty("ingestedPositions", out JsonElement ingestedPositions)) @@ -717,6 +817,11 @@ private static void ValidateMessageArray(JsonElement messages, string location) $"Durable agent {location} contains a non-object message."); } + RequireStringWhenPresent(message, "authorName", $"{location}.authorName"); + RequireDateTimeWhenPresent(message, "createdAt", $"{location}.createdAt"); + RequireStringWhenPresent(message, "messageId", $"{location}.messageId"); + RequireObjectWhenPresent(message, ExtensionDataPropertyName, $"{location}.extensionData"); + if (!message.TryGetProperty("contents", out JsonElement contents)) { continue; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index 1d7e09d..1bea283 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -93,6 +93,14 @@ private static DurableAgentStateMessage FromChatMessage( bool requireJsonSafeMetadata, ILogger? logger) { + string role = message.Role.ToString(); + if (!requireJsonSafeMetadata && + role is not ("user" or "assistant" or "system" or "tool")) + { + throw new InvalidOperationException( + $"The legacy durable agent state cannot persist message role '{role}'."); + } + Dictionary? additionalProperties = null; if (message.AdditionalProperties is not null) { @@ -114,9 +122,11 @@ private static DurableAgentStateMessage FromChatMessage( AuthorName = message.AuthorName, MessageId = message.MessageId ?? generatedMessageId, AdditionalProperties = additionalProperties, - Role = message.Role.ToString(), + Role = role, Contents = message.Contents.Select(content => - DurableAgentStateContent.FromAIContent(content, logger)).ToList() + requireJsonSafeMetadata + ? DurableAgentStateContent.FromAIContentV2(content, logger) + : DurableAgentStateContent.FromAIContent(content, logger)).ToList() }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 1354664..07335cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -59,9 +59,10 @@ In revised state, `terminalResults` stores immutable result envelopes by correla `conversationHistory`, while `completionReceipts` retains completion evidence after a result payload expires. An `available` receipt requires a matching result; an `unavailable` receipt proves completion without a result payload while retaining its outcome. Absence of a receipt means only that no terminal completion is recorded; -it does not distinguish an accepted pending request from an unknown identity. Optional `historyBinding` records -a provisional configured facility kind and stable logical provider key. It does not establish effective -per-run ownership, and the key must not be inferred from CLR type names or opaque session keys. +it does not distinguish an accepted pending request from an unknown identity. Optional `historyBinding` is an +opaque, separately versioned runtime profile. Non-relying consumers preserve it without interpreting any +nested field. Only a relying runtime may validate a profile it recognizes against trusted host configuration; +the shared contract defines no owner kind, provider key, default, or transition policy. 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. diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index 9acb5fd..e10bb5e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -23,6 +23,9 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs index 39d7df8..62c9493 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs @@ -131,5 +131,24 @@ public void StringArgumentsRoundTripVerbatimWithoutParsing() roundTripDocument.RootElement.GetProperty("arguments").GetString()); } + [Fact] + public void ProductionMappingDoesNotEmitV2StringArguments() + { + FunctionCallContent runtime = new("call-8", "future") + { + RawRepresentation = "verbatim", + }; + + DurableAgentStateFunctionCallContent legacy = + Assert.IsType( + DurableAgentStateContent.FromAIContent(runtime)); + DurableAgentStateFunctionCallContent revised = + Assert.IsType( + DurableAgentStateContent.FromAIContentV2(runtime)); + + Assert.Equal(JsonValueKind.Undefined, legacy.Arguments.ValueKind); + Assert.Equal("verbatim", revised.Arguments.GetString()); + } + private sealed record Location(string City, string State); } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs index b973f66..b7331d4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs @@ -10,6 +10,112 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateMailboxTests { + [Fact] + public void VersionedEnvelopeCasesMatchDotNetReaders() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "versioned-envelope-cases.json")); + using JsonDocument document = JsonDocument.Parse(json); + int caseCount = 0; + int schemaOnlyLegacyCases = 0; + + foreach (JsonElement group in document.RootElement.EnumerateArray()) + { + foreach (JsonElement test in group.GetProperty("tests").EnumerateArray()) + { + string stateJson = test.GetProperty("data").GetRawText(); + bool valid = test.GetProperty("valid").GetBoolean(); + if (valid) + { + JsonElement data = test.GetProperty("data"); + if (IsSchemaOnlyLegacyEntryCase(data)) + { + // Historical schema snapshots allowed an undiscriminated generic entry. + // The existing .NET model has always required a typed entry discriminator; + // this implementation must not invent one while round-tripping old data. + schemaOnlyLegacyCases++; + } + else + { + DurableAgentState state = Deserialize(stateJson); + _ = Serialize(state); + } + } + else + { + Assert.ThrowsAny(() => Deserialize(stateJson)); + } + + caseCount++; + } + } + + Assert.Equal(44, caseCount); + Assert.Equal(3, schemaOnlyLegacyCases); + } + + [Theory] + [InlineData("""{"role":"developer","contents":[]}""")] + [InlineData("""{"role":"assistant","contents":[{"$type":"functionCall","callId":"c","name":"f","arguments":"verbatim"}]}""")] + [InlineData("""{"role":"assistant","contents":[{"$type":"uri","uri":"https://example.test/media"}]}""")] + public void LegacySnapshotsRejectV2OnlyMessageShapes(string messageJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{{messageJson}}] + }] + } + } + """; + + Assert.Throws(() => Deserialize(json)); + } + + [Theory] + [InlineData("null")] + [InlineData("[null]")] + public void LegacySnapshotsRejectMalformedConversationHistory(string historyJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": {{historyJson}} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("""{"$type":"request","messages":null}""")] + [InlineData("""{"$type":"request","messages":[null]}""")] + [InlineData("""{"$type":"request","messages":[{"role":null}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":null}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[null]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"text","text":null}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"functionCall","callId":null,"name":"f"}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"uri","uri":null,"mediaType":"text/plain"}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"usage","usage":null}]}]}""")] + public void LegacySnapshotsRejectMalformedEntryShapes(string entryJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{{entryJson}}] + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + [Fact] public void LegacyStateRoundTripsWithoutRevisedFields() { @@ -69,7 +175,9 @@ public void RevisedFixtureRoundTripsTypedMailboxAndFutureFields() state.Data.CompletionReceipts?["corr-expired"]); Assert.Equal(DurableAgentState.RevisedSchemaVersion, state.SchemaVersion); - Assert.Equal("contoso.support-history.v1", state.Data.HistoryBinding?.ProviderKey); + Assert.Equal( + "contoso.support-history.v1", + state.Data.HistoryBinding.GetProperty("providerKey").GetString()); Assert.Equal("response-id-2", result.Response?.ResponseId); Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, unavailable.ResultState); Assert.Contains("\"futureResponseField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); @@ -91,7 +199,7 @@ public void RevisedStateRequiresCompleteLayout(string missingProperty) ["historyBinding"] = new { version = 1, - ownerKind = DurableAgentStateHistoryBinding.DurableStateOwner, + ownerKind = "durableState", providerKey = "durable-state.v1", }, }; @@ -121,7 +229,7 @@ public void RevisedStateAllowsOmittedHistoryBinding() DurableAgentState state = Deserialize(Json); - Assert.Null(state.Data.HistoryBinding); + Assert.Equal(JsonValueKind.Undefined, state.Data.HistoryBinding.ValueKind); } [Theory] @@ -509,11 +617,7 @@ public void ExplicitResultRemovalMayPrecedeScheduledExpiry() [Fact] public void TerminalErrorLengthCountsUnicodeScalars() { - DurableAgentState state = CreateEmptyRevisedState(new() - { - OwnerKind = DurableAgentStateHistoryBinding.DurableStateOwner, - ProviderKey = "durable-state.v1", - }); + DurableAgentState state = CreateEmptyRevisedState(); const string CorrelationId = "failed"; DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:00+00:00"); state.Data.TerminalResults![CorrelationId] = new() @@ -553,30 +657,33 @@ public void TerminalResponseMetadataRequiresValidKeys() Assert.Throws(() => Deserialize(json)); } - [Theory] - [InlineData(2, DurableAgentStateHistoryBinding.DurableStateOwner, "durable-state.v1")] - [InlineData(1, "futureOwner", "provider.v1")] - [InlineData(1, DurableAgentStateHistoryBinding.HistoryProviderOwner, " ")] - public void InvalidHistoryBindingIsRejected(int version, string ownerKind, string providerKey) - { - DurableAgentState state = CreateEmptyRevisedState(new() - { - Version = version, - OwnerKind = ownerKind, - ProviderKey = providerKey, - }); - - Assert.Throws(() => Serialize(state)); - } - [Fact] - public void HistoryBindingRequiresExplicitWireVersion() + public void HistoryBindingIsPreservedAsOpaqueRuntimeProfile() { - string json = File.ReadAllText( - Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) - .Replace("\"version\": 1,", string.Empty, StringComparison.Ordinal); + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": { + "runtime": "csharp", + "version": -1, + "ownerKind": null, + "nested": { + "$runtimeType": "inert" + } + } + } + } + """; - Assert.Throws(() => Deserialize(json)); + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"version\":-1", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"ownerKind\":null", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"$runtimeType\":\"inert\"", roundTrip, StringComparison.Ordinal); } [Fact] @@ -835,7 +942,7 @@ public void LegacyStateRejectsPresentNullRevisedFields(string propertyName) } [Fact] - public void RevisedStateRejectsNullHistoryBindingWhenPresent() + public void RevisedStatePreservesNullHistoryProfileWhenPresent() { const string Json = """ { @@ -849,7 +956,9 @@ public void RevisedStateRejectsNullHistoryBindingWhenPresent() } """; - Assert.Throws(() => Deserialize(Json)); + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"historyBinding\":null", roundTrip, StringComparison.Ordinal); } [Theory] @@ -1099,20 +1208,91 @@ public void RevisedStateRejectsMalformedKnownContent(string contentJson) Assert.ThrowsAny(() => Deserialize(json)); } + [Theory] + [InlineData("inputTokenCount")] + [InlineData("outputTokenCount")] + [InlineData("totalTokenCount")] + [InlineData("extensionData")] + public void LegacyUsageContentRejectsExplicitNullKnownFields(string propertyName) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [{ + "$type": "usage", + "usage": { + "{{propertyName}}": null + } + }] + }] + }] + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("\"authorName\":null")] + [InlineData("\"messageId\":null")] + [InlineData("\"createdAt\":null")] + [InlineData("\"extensionData\":null")] + public void TerminalMessagesRejectExplicitNullKnownFields(string property) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-12T00:00:00Z", + "response": { + "messages": [{ + "role": "assistant", + {{property}} + }] + } + } + }, + "completionReceipts": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-12T00:00:00Z", + "resultState": "available" + } + } + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + [Fact] public void IdentifierLengthCountsUnicodeScalars() { string providerKey = string.Concat(Enumerable.Repeat("\U0001F600", 200)); - DurableAgentState state = CreateEmptyRevisedState(new() - { - OwnerKind = DurableAgentStateHistoryBinding.HistoryProviderOwner, - ProviderKey = providerKey, - }); + DurableAgentState state = CreateEmptyRevisedState( + JsonSerializer.SerializeToElement(new + { + ownerKind = "historyProvider", + providerKey, + })); string json = Serialize(state); DurableAgentState restored = Deserialize(json); - Assert.Equal(providerKey, restored.Data.HistoryBinding?.ProviderKey); + Assert.Equal(providerKey, restored.Data.HistoryBinding.GetProperty("providerKey").GetString()); } [Fact] @@ -1244,7 +1424,7 @@ public void KnownContentPreservesExplicitNullVersusAbsent() Assert.False(contents[2].TryGetProperty("result", out _)); } - private static DurableAgentState CreateEmptyRevisedState(DurableAgentStateHistoryBinding binding) + private static DurableAgentState CreateEmptyRevisedState(JsonElement binding = default) { return new() { @@ -1316,4 +1496,17 @@ private static string Serialize(DurableAgentState state) => state.SchemaVersion == DurableAgentState.RevisedSchemaVersion ? DurableAgentStateJsonConverter.SerializeRevisedContract(state) : JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + private static bool IsSchemaOnlyLegacyEntryCase(JsonElement state) + { + if (state.GetProperty("schemaVersion").GetString() == DurableAgentState.RevisedSchemaVersion || + !state.GetProperty("data").TryGetProperty("conversationHistory", out JsonElement history)) + { + return false; + } + + return history.EnumerateArray().Any(entry => + entry.ValueKind == JsonValueKind.Object && + !entry.TryGetProperty("$type", out _)); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs index acc7042..85a72f8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs @@ -8,6 +8,19 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateMessageTests { + [Fact] + public void ProductionMappingRejectsV2OnlyDeveloperRole() + { + ChatMessage message = new(new ChatRole("developer"), "instruction"); + + Assert.Throws( + () => DurableAgentStateMessage.FromChatMessage(message)); + + DurableAgentStateMessage revised = + DurableAgentStateMessage.FromTerminalChatMessage(message); + Assert.Equal("developer", revised.Role); + } + [Fact] public void MessageSerializationDeserialization() { diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs index d6ffe25..8a0f0a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -352,7 +352,7 @@ public void BasicState() "createdAt": "2024-01-01T12:01:00Z", "messages": [ { - "role": "agent", + "role": "assistant", "contents": [ { "$type": "text", @@ -395,7 +395,7 @@ public void BasicState() Assert.Equal("12345", entry.CorrelationId); Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:01:00Z"), entry.CreatedAt); Assert.Single(entry.Messages); - Assert.Equal("agent", entry.Messages[0].Role); + Assert.Equal("assistant", entry.Messages[0].Role); Assert.Single(entry.Messages[0].Contents); DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents); DurableAgentStateTextContent textContent = Assert.IsType(content); From 86bf671dd29ad950b0cb6949b95a415a1714fe2f Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Sat, 12 Sep 2026 07:22:37 +0300 Subject: [PATCH 6/6] Disambiguate durable unknown content metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 532fe4f5-939b-4962-989d-a1883dccd283 --- .../State/DurableAgentStateTerminalError.cs | 2 +- .../State/DurableAgentStateUnknownContent.cs | 8 +++++ .../State/DurableAgentStateContentTests.cs | 32 +++++++++++++++++++ .../State/DurableAgentStateMailboxTests.cs | 14 ++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs index 4f50a7c..57953bf 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs @@ -36,7 +36,7 @@ public void Validate() .Count() > DurableAgentStateContract.MaxMetadataStringLength) { throw new InvalidOperationException( - $"The durable agent terminal error message must be non-empty, at most {DurableAgentStateContract.MaxMetadataStringLength} characters, and contain no control characters."); + $"The durable agent terminal error message must contain a non-whitespace character and be at most {DurableAgentStateContract.MaxMetadataStringLength} Unicode characters."); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs index 05bd4e4..15b4f00 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs @@ -17,6 +17,7 @@ internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent { private const string DurableEnvelopePropertyName = "$microsoftAgentFrameworkDurableTask"; private const string KindPropertyName = "kind"; + private const string MarkerPropertyName = "marker"; private const string VersionPropertyName = "version"; private const string AnnotationsPropertyName = "annotations"; private const string AdditionalPropertiesPropertyName = "additionalProperties"; @@ -24,6 +25,8 @@ internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent private const string AnnotatedRegionsPropertyName = "annotatedRegions"; private const string OmittedPropertyName = "omitted"; private const string UnknownContentKind = "unknownAIContent"; + private const string DurableEnvelopeMarker = + "Microsoft.Agents.AI.DurableTask.UnknownContent/9d3df45a-6345-4b0e-88c6-972497582abc"; private const int DurableEnvelopeVersion = 1; private static readonly JsonElement s_minimalUnknownContent = CreateMinimalUnknownContent(); @@ -87,6 +90,7 @@ private static JsonObject CreateEnvelope(string kind) return new JsonObject { [KindPropertyName] = kind, + [MarkerPropertyName] = DurableEnvelopeMarker, [VersionPropertyName] = DurableEnvelopeVersion, }; } @@ -417,6 +421,9 @@ private static bool TryGetEnvelope( envelope.ValueKind != JsonValueKind.Object || !envelope.TryGetProperty(KindPropertyName, out JsonElement kindElement) || kindElement.ValueKind != JsonValueKind.String || + !envelope.TryGetProperty(MarkerPropertyName, out JsonElement markerElement) || + markerElement.ValueKind != JsonValueKind.String || + markerElement.GetString() != DurableEnvelopeMarker || !envelope.TryGetProperty(VersionPropertyName, out JsonElement versionElement) || versionElement.ValueKind != JsonValueKind.Number || !versionElement.TryGetInt32(out int version) || @@ -437,6 +444,7 @@ private static bool TryReadUnknownContent( if (!HasOnlyProperties( envelope, KindPropertyName, + MarkerPropertyName, VersionPropertyName, AnnotationsPropertyName, AdditionalPropertiesPropertyName, diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs index 6e1badc..15eae74 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs @@ -499,6 +499,34 @@ public void FutureDurableEnvelopeFieldsRemainOpaque() Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); } + [Fact] + public void MarkerShapedProducerContentWithoutEnvelopeMarkerRemainsOpaque() + { + using JsonDocument document = JsonDocument.Parse( + """ + { + "$microsoftAgentFrameworkDurableTask": { + "kind": "unknownAIContent", + "version": 1 + } + } + """); + JsonElement original = document.RootElement.Clone(); + DurableAgentStateUnknownContent stored = new() { Content = original }; + + AIContent restored = Assert.IsType(stored.ToAIContent()); + DurableAgentStateUnknownContent roundTripped = Assert.IsType( + DurableAgentStateContent.FromAIContent(restored)); + + Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); + Assert.Equal( + "unknownAIContent", + roundTripped.Content + .GetProperty("$microsoftAgentFrameworkDurableTask") + .GetProperty("kind") + .GetString()); + } + [Fact] public void UnregisteredAIContentSubtypePersistsCommonContractAsUnknown() { @@ -533,6 +561,10 @@ public void UnregisteredAIContentSubtypePersistsCommonContractAsUnknown() JsonElement envelope = persistedContent.GetProperty("$microsoftAgentFrameworkDurableTask"); Assert.Equal("unknownAIContent", envelope.GetProperty("kind").GetString()); + Assert.StartsWith( + "Microsoft.Agents.AI.DurableTask.UnknownContent/", + envelope.GetProperty("marker").GetString(), + StringComparison.Ordinal); Assert.Equal(1, envelope.GetProperty("version").GetInt32()); Assert.False(persistedContent.TryGetProperty("$runtimeType", out _)); Assert.DoesNotContain(typeof(FutureContent).FullName!, json, StringComparison.Ordinal); 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 b7331d4..80df8d6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs @@ -647,6 +647,20 @@ public void TerminalErrorLengthCountsUnicodeScalars() Deserialize(json).Data.TerminalResults![CorrelationId].Error!.Message.EnumerateRunes().Count()); } + [Fact] + public void TerminalErrorMessageAllowsContractValidControlCharacters() + { + DurableAgentStateTerminalError error = new() + { + Code = "Example", + Message = "line\u0001break", + }; + + error.Validate(); + + Assert.Contains('\u0001', error.Message); + } + [Fact] public void TerminalResponseMetadataRequiresValidKeys() {