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/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..9bb1770 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Agents.AI.DurableTask.State; @@ -10,6 +11,12 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonConverter(typeof(DurableAgentStateJsonConverter))] internal sealed class DurableAgentState { + internal const string CurrentSchemaVersion = "1.2.0"; + internal const string RevisedSchemaVersion = "2.0.0"; + internal const int RevisedSchemaMajorVersion = 2; + private static readonly DurableAgentStateSchemaVersion s_currentSchemaVersion = + DurableAgentStateSchemaVersion.ParseSupported(CurrentSchemaVersion); + /// /// Gets the data of the durable agent. /// @@ -20,8 +27,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 declared version must be promoted for a legacy write. Only exact schema + /// snapshots reviewed by the shared contract are accepted; later versions fail closed. /// [JsonPropertyName("schemaVersion")] - public string SchemaVersion { get; init; } = "1.1.0"; + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// + /// 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/DurableAgentStateCompletionReceipt.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs new file mode 100644 index 0000000..bfccea3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompletionReceipt.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Immutable evidence that a correlation completed, retained independently from its result payload. +/// +internal sealed class DurableAgentStateCompletionReceipt +{ + public const string SucceededOutcome = "succeeded"; + public const string FailedOutcome = "failed"; + public const string AvailableResult = "available"; + public const string UnavailableResult = "unavailable"; + + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + [JsonPropertyName("outcome")] + public required string Outcome { get; init; } + + [JsonPropertyName("completedAt")] + public required DateTimeOffset CompletedAt { get; init; } + + [JsonPropertyName("resultState")] + public required string ResultState { get; init; } + + [JsonPropertyName("resultExpiresAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultExpiresAt { get; init; } + + [JsonPropertyName("resultUnavailableAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultUnavailableAt { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate(string dictionaryKey) + { + DurableAgentStateContract.ValidateIdentifier(dictionaryKey, "completionReceipts key"); + DurableAgentStateContract.ValidateIdentifier(this.CorrelationId, "completionReceipts.correlationId"); + if (!string.Equals(dictionaryKey, this.CorrelationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The durable agent state completion receipt key '{dictionaryKey}' does not match correlation ID '{this.CorrelationId}'."); + } + + if (this.Outcome is not SucceededOutcome and not FailedOutcome) + { + throw new InvalidOperationException( + $"The durable agent state completion outcome '{this.Outcome}' is not supported."); + } + + if (this.CompletedAt == default) + { + throw new InvalidOperationException( + "A durable agent completion receipt requires a completion timestamp."); + } + + if (this.ResultState is not AvailableResult and not UnavailableResult) + { + throw new InvalidOperationException( + $"The durable agent state result state '{this.ResultState}' is not supported."); + } + + if (this.ResultExpiresAt < this.CompletedAt) + { + throw new InvalidOperationException( + "The durable agent state result expiry cannot precede completion."); + } + + if (this.ResultState == AvailableResult && this.ResultUnavailableAt is not null) + { + throw new InvalidOperationException( + "An available durable agent result cannot have an unavailable timestamp."); + } + + if (this.ResultState == UnavailableResult && + (this.ResultUnavailableAt is null || this.ResultUnavailableAt < this.CompletedAt)) + { + throw new InvalidOperationException( + "An unavailable durable agent result requires an unavailable timestamp at or after completion."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs index 3ae7d12..ea79b9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -42,10 +43,10 @@ internal abstract class DurableAgentStateContent JsonSerializer.SerializeToElement(value: null, jsonTypeInfo: s_objectTypeInfo); /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets unknown content properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Converts this durable agent state content to an . @@ -53,18 +54,38 @@ internal abstract class DurableAgentStateContent /// A converted instance. public abstract AIContent ToAIContent(); + /// + /// Validates semantic constraints introduced by the schema 2.0 contract. + /// + public virtual void ValidateV2() + { + } + /// /// Creates a from an . /// /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original . - public static DurableAgentStateContent FromAIContent(AIContent content) + public static DurableAgentStateContent FromAIContent(AIContent content, ILogger? logger = null) + => FromAIContent(content, allowLosslessV2: false, logger); + + internal static DurableAgentStateContent FromAIContentV2(AIContent content, ILogger? logger = null) + => FromAIContent(content, allowLosslessV2: true, logger); + + private static DurableAgentStateContent FromAIContent( + AIContent content, + bool allowLosslessV2, + ILogger? logger) { return content switch { DataContent dataContent => DurableAgentStateDataContent.FromDataContent(dataContent), ErrorContent errorContent => DurableAgentStateErrorContent.FromErrorContent(errorContent), - FunctionCallContent functionCallContent => DurableAgentStateFunctionCallContent.FromFunctionCallContent(functionCallContent), + FunctionCallContent functionCallContent => + DurableAgentStateFunctionCallContent.FromFunctionCallContent( + functionCallContent, + allowLosslessV2), FunctionResultContent functionResultContent => DurableAgentStateFunctionResultContent.FromFunctionResultContent(functionResultContent), HostedFileContent hostedFileContent => DurableAgentStateHostedFileContent.FromHostedFileContent(hostedFileContent), HostedVectorStoreContent hostedVectorStoreContent => DurableAgentStateHostedVectorStoreContent.FromHostedVectorStoreContent(hostedVectorStoreContent), @@ -72,7 +93,7 @@ public static DurableAgentStateContent FromAIContent(AIContent content) TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent), UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent), UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent), - _ => DurableAgentStateUnknownContent.FromUnknownContent(content) + _ => DurableAgentStateUnknownContent.FromUnknownContent(content, logger) }; } @@ -95,7 +116,7 @@ protected static JsonElement ToJsonElement(object? value) return value switch { null => s_nullElement, - JsonElement element => element, + JsonElement element => element.Clone(), _ => JsonSerializer.SerializeToElement(value: value, jsonTypeInfo: s_objectTypeInfo) }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs new file mode 100644 index 0000000..7650198 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContract.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +internal static class DurableAgentStateContract +{ + public const int MaxIdentifierLength = 256; + public const int MaxMetadataKeyLength = 256; + public const int MaxMetadataStringLength = 16 * 1024; + + public static void ValidateIdentifier(string? value, string propertyName) + { + if (string.IsNullOrWhiteSpace(value) || + value.EnumerateRunes().Take(MaxIdentifierLength + 1).Count() > MaxIdentifierLength || + value.Any(char.IsControl)) + { + throw new InvalidOperationException( + $"The durable agent state '{propertyName}' property must be a non-empty string of at most {MaxIdentifierLength} characters without control characters."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index 745f619..7967f53 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -17,16 +17,221 @@ internal sealed class DurableAgentStateData [JsonPropertyName("conversationHistory")] public IList ConversationHistory { get; init; } = []; + /// + /// Gets immutable terminal result payloads indexed by correlation ID. + /// + [JsonPropertyName("terminalResults")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? TerminalResults { get; init; } + + /// + /// Gets completion receipts retained independently from result payload expiry. + /// + [JsonPropertyName("completionReceipts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? CompletionReceipts { get; init; } + + /// + /// Gets an optional, separately versioned runtime history profile. + /// + /// + /// The shared contract treats this object as opaque. This layer preserves its complete JSON shape + /// without interpreting owner fields, inferring defaults, or constraining per-run ownership transitions. + /// A relying C# profile may apply stricter validation in a later layer. + /// + [JsonPropertyName("historyBinding")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement HistoryBinding + { + get; + init + { + field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + } + + /// + /// Gets or sets the opaque state produced by the configured agent's session serialization contract. + /// + /// + /// This value can contain service conversation identity, continuation state, and provider-specific + /// state that cannot be reduced to a conversation ID. The durable state layer owns only the JSON + /// representation: it requires an object, clones assigned values away from caller-owned + /// instances, and round-trips the object without interpreting property + /// names such as $type or $runtimeType. It never uses this JSON to select or construct a + /// CLR type. A later integration layer may return the object only to the configured agent through + /// that agent's session deserialization contract. + /// + /// The normal System.Text.Json nesting limit applies when the enclosing state is parsed. + /// This schema layer intentionally has no independent byte cap because valid opaque provider state can + /// vary in size; the durable entity storage budget and retention policy remain the outer trust boundary. + /// Producers must therefore treat session state as persisted data, not as a trusted instruction or an + /// object graph. + /// + [JsonPropertyName("session")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Session + { + get; + set + { + if (value is not JsonElement element) + { + field = null; + return; + } + + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.session' property must be a JSON object."); + } + + field = element.Clone(); + } + } + + /// + /// Gets or sets the highest legacy scalar conversation position seen from each workflow producer. + /// + /// + /// This field records only the greatest observed position. It does not prove a contiguous delivered + /// prefix: after seeing positions 1 and 3, the scalar value 3 does not establish that position 2 was + /// delivered. It is distinct from the exact completion-receipt design used for terminal delivery. + /// The current .NET and Python production paths do not produce or consume these values; .NET preserves + /// and round-trips them so state written by a compatible workflow implementation is not discarded. + /// + [JsonPropertyName("ingestedPositions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? IngestedPositions { get; set; } + + /// + /// Gets or sets bounded evidence that transcript messages were removed from durable state. + /// + /// + /// The evidence persists after the corresponding transcript entries are gone and records the cumulative + /// count plus the first and latest eviction times. This lets readers and operators distinguish an + /// intentionally truncated transcript from one in which the missing messages were never persisted. + /// It is diagnostic provenance only: it is not model context, a terminal result, or proof that a + /// correlation completed. This layer preserves the contract but does not currently produce or consume + /// truncation evidence. + /// + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTruncation? Truncation { get; set; } + /// /// Gets or sets the expiration time (UTC) for this agent entity. /// If the entity is idle beyond this time, it will be automatically deleted. /// [JsonPropertyName("expirationTimeUtc")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? ExpirationTimeUtc { get; set; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets producer-defined values from the schema's declared data-level extensionData field. + /// + /// + /// This is an explicit interoperability field. It is separate from , + /// which captures undeclared future JSON members through . + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets undeclared future data properties that appear beside the schema's known fields. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } + + public void Validate(string schemaVersion) + { + DurableAgentStateSchemaVersion version = + DurableAgentStateSchemaVersion.ParseSupported(schemaVersion); + if (this.IngestedPositions?.Values.Any(static position => position < 0) == true) + { + throw new InvalidOperationException( + "Durable agent ingestion positions must be non-negative."); + } + + this.Truncation?.Validate(); + + if (version.Major == DurableAgentState.RevisedSchemaMajorVersion) + { + if (this.ConversationHistory is null) + { + throw new InvalidOperationException( + "A revised durable agent state requires a conversation history collection."); + } + + if (this.TerminalResults is null || + this.CompletionReceipts is null) + { + throw new InvalidOperationException( + "A revised durable agent state requires terminal results and completion receipts."); + } + + Dictionary terminalResults = + this.TerminalResults.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal); + Dictionary completionReceipts = + this.CompletionReceipts.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal); + + foreach (DurableAgentStateEntry? entry in this.ConversationHistory) + { + if (entry is null) + { + throw new InvalidOperationException( + "A revised durable agent state cannot contain null conversation entries."); + } + + entry.ValidateV2(); + } + + foreach ((string correlationId, DurableAgentStateTerminalResult result) in this.TerminalResults) + { + result.Validate(correlationId); + if (!completionReceipts.TryGetValue( + correlationId, + out DurableAgentStateCompletionReceipt? receipt)) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{correlationId}' has no completion receipt."); + } + + if (receipt.ResultState != DurableAgentStateCompletionReceipt.AvailableResult || + receipt.Outcome != result.Outcome || + receipt.CompletedAt != result.CompletedAt || + receipt.ResultExpiresAt != result.ResultExpiresAt) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{correlationId}' is inconsistent with its completion receipt."); + } + } + + foreach ((string correlationId, DurableAgentStateCompletionReceipt receipt) in this.CompletionReceipts) + { + receipt.Validate(correlationId); + bool hasResult = terminalResults.ContainsKey(correlationId); + if (receipt.ResultState == DurableAgentStateCompletionReceipt.AvailableResult != hasResult) + { + throw new InvalidOperationException( + $"Durable agent completion receipt '{correlationId}' is inconsistent with result availability."); + } + } + } + else if (this.TerminalResults is not null || + this.CompletionReceipts is not null || + this.HistoryBinding.ValueKind != JsonValueKind.Undefined) + { + throw new InvalidOperationException( + "Mailbox and provisional history-binding fields require durable agent state schema version 2.0.0."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs index 2f04c90..4db9af4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs @@ -12,6 +12,8 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(DurableAgentStateRequest), "request")] [JsonDerivedType(typeof(DurableAgentStateResponse), "response")] +[JsonDerivedType(typeof(DurableAgentStateErrorResponse), "errorResponse")] +[JsonDerivedType(typeof(DurableAgentStateCompaction), "compaction")] internal abstract class DurableAgentStateEntry { /// @@ -19,26 +21,68 @@ internal abstract class DurableAgentStateEntry /// /// /// This ID is used to correlate back to its - /// . + /// . Compaction entries do not have a correlation ID. /// [JsonPropertyName("correlationId")] - public required string CorrelationId { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CorrelationId { get; init; } /// /// Gets the timestamp when this entry was created. /// [JsonPropertyName("createdAt")] - public required DateTimeOffset CreatedAt { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } /// /// Gets the list of messages associated with this entry, in chronological order. /// [JsonPropertyName("messages")] - public IReadOnlyList Messages { get; init; } = []; + public IReadOnlyList Messages + { + get; + init => field = value ?? []; + } = []; /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets application-defined entry metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown entry properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } + + public void ValidateV2() + { + if (this is DurableAgentStateCompaction) + { + if (this.CorrelationId is not null) + { + throw new InvalidOperationException( + "A durable agent compaction entry cannot have a correlation ID."); + } + } + else if (this.CorrelationId is not null) + { + DurableAgentStateContract.ValidateIdentifier( + this.CorrelationId, + "conversationHistory.correlationId"); + } + + foreach (DurableAgentStateMessage? message in this.Messages) + { + if (message is null) + { + throw new InvalidOperationException( + "A revised durable agent state cannot contain null messages."); + } + + message.ValidateV2(); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs index 17e5fea..b73a603 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -28,8 +29,12 @@ internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent /// Gets the error details. /// [JsonPropertyName("details")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Details { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Details + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } /// /// Creates a from an . @@ -41,7 +46,11 @@ public static DurableAgentStateErrorContent FromErrorContent(ErrorContent conten { return new DurableAgentStateErrorContent() { - Details = content.Details, + Details = content.Details is null + ? default + : JsonSerializer.SerializeToElement( + content.Details, + DurableAgentStateJsonContext.Default.String), ErrorCode = content.ErrorCode, Message = content.Message }; @@ -52,7 +61,12 @@ public override AIContent ToAIContent() { return new ErrorContent(this.Message) { - Details = this.Details, + Details = this.Details.ValueKind switch + { + JsonValueKind.Undefined => null, + JsonValueKind.String => this.Details.GetString(), + _ => this.Details.GetRawText(), + }, ErrorCode = this.ErrorCode }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs new file mode 100644 index 0000000..bb5742c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a failed turn recorded by another durable agent implementation. +/// +/// +/// .NET durable agents do not currently create pollable error responses, but preserve this shared-schema +/// entry kind when reading state written by another language implementation. +/// +internal sealed class DurableAgentStateErrorResponse : DurableAgentStateResponse; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs index 8b655e1..5ade09c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -13,19 +12,14 @@ namespace Microsoft.Agents.AI.DurableTask.State; internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent { /// - /// The function call arguments, each encoded as JSON. + /// Gets the original function-call arguments as an object or verbatim string. /// /// - /// Arguments produced by a chat client from a model response are already - /// values, but callers can supply containing arbitrary objects (for - /// example when replaying history or resuming an approval). Those are encoded here using - /// so that persisting the state cannot fail on a type the - /// state serializer has no metadata for. + /// String form is preserved without parsing or normalization, including incomplete or non-JSON text. /// - /// TODO: Consider ensuring that empty dictionaries are omitted from serialization. [JsonPropertyName("arguments")] - public required IReadOnlyDictionary Arguments { get; init; } = - ImmutableDictionary.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Arguments { get; init; } /// /// Gets the function call identifier. @@ -47,18 +41,32 @@ internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateCo /// Creates a from a . /// /// The to convert. + /// Whether v2-only verbatim string arguments may be persisted. /// /// A representing the original content. /// - public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content) + public static DurableAgentStateFunctionCallContent FromFunctionCallContent( + FunctionCallContent content, + bool allowLosslessV2 = false) { - Dictionary arguments = []; - if (content.Arguments is not null) + JsonElement arguments = default; + if (allowLosslessV2 && content.RawRepresentation is string encodedArguments) { + arguments = JsonSerializer.SerializeToElement( + encodedArguments, + DurableAgentStateJsonContext.Default.String); + } + else if (content.Arguments is not null) + { + Dictionary argumentValues = []; foreach (KeyValuePair argument in content.Arguments) { - arguments[argument.Key] = ToJsonElement(argument.Value); + argumentValues[argument.Key] = ToJsonElement(argument.Value); } + + arguments = JsonSerializer.SerializeToElement( + argumentValues, + DurableAgentStateJsonContext.Default.DictionaryStringJsonElement); } return new DurableAgentStateFunctionCallContent() @@ -72,12 +80,38 @@ public static DurableAgentStateFunctionCallContent FromFunctionCallContent(Funct /// public override AIContent ToAIContent() { - Dictionary arguments = new(this.Arguments.Count); - foreach (KeyValuePair argument in this.Arguments) + if (this.Arguments.ValueKind == JsonValueKind.String) { - arguments[argument.Key] = argument.Value; + string encodedArguments = this.Arguments.GetString()!; + return new FunctionCallContent(this.CallId, this.Name) + { + RawRepresentation = encodedArguments, + }; + } + + Dictionary? arguments = + this.Arguments.ValueKind == JsonValueKind.Undefined ? [] : null; + if (this.Arguments.ValueKind == JsonValueKind.Object) + { + arguments = []; + foreach (JsonProperty argument in this.Arguments.EnumerateObject()) + { + arguments[argument.Name] = argument.Value.Clone(); + } } return new FunctionCallContent(this.CallId, this.Name, arguments); } + + /// + public override void ValidateV2() + { + if (this.Arguments.ValueKind is not JsonValueKind.Undefined and + not JsonValueKind.Object and + not JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent function-call arguments must be an object, a verbatim string, or absent."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs index 8c79d67..b5a1af8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs @@ -32,8 +32,12 @@ internal sealed class DurableAgentStateFunctionResultContent : DurableAgentState /// persisted under this single property. /// [JsonPropertyName("result")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Result { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Result + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } /// /// Creates a from a . @@ -48,15 +52,14 @@ public static DurableAgentStateFunctionResultContent FromFunctionResultContent(F // A null result is left absent rather than encoded as a JSON null so that it round trips // back to a null FunctionResultContent.Result. - Result = content.Result is null ? null : ToJsonElement(content.Result) + Result = content.Result is null ? default : ToJsonElement(content.Result) }; } /// public override AIContent ToAIContent() { - // Boxing a JsonElement? yields either a boxed JsonElement or null, matching the shape chat - // clients expect from a tool whose result was marshalled into JSON. - return new FunctionResultContent(this.CallId, this.Result); + object? result = this.Result.ValueKind == JsonValueKind.Undefined ? null : this.Result; + return new FunctionResultContent(this.CallId, result); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index 4ad9a62..edfb2ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -11,10 +11,19 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(DurableAgentStateContent))] [JsonSerializable(typeof(DurableAgentStateData))] [JsonSerializable(typeof(DurableAgentStateEntry))] +[JsonSerializable(typeof(DurableAgentStateErrorResponse))] +[JsonSerializable(typeof(DurableAgentStateCompaction))] [JsonSerializable(typeof(DurableAgentStateMessage))] +[JsonSerializable(typeof(DurableAgentStateTruncation))] +[JsonSerializable(typeof(DurableAgentStateCompletionReceipt))] +[JsonSerializable(typeof(DurableAgentStateTerminalResult))] +[JsonSerializable(typeof(DurableAgentStateTerminalResponse))] +[JsonSerializable(typeof(DurableAgentStateTerminalError))] // Function call and result content [JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(object))] [JsonSerializable(typeof(JsonDocument))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonNode))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index 4c7796b..c13e634 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace Microsoft.Agents.AI.DurableTask.State; @@ -10,8 +12,13 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// internal sealed class DurableAgentStateJsonConverter : JsonConverter { + private static readonly Regex s_rfc3339Pattern = new( + @"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$", + RegexOptions.CultureInvariant); + private const string SchemaVersionPropertyName = "schemaVersion"; private const string DataPropertyName = "data"; + private const string ExtensionDataPropertyName = "extensionData"; /// public override DurableAgentState? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -20,6 +27,29 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter? extensionData = + element.Value.TryGetProperty(ExtensionDataPropertyName, out JsonElement extensionDataElement) + ? ReadExtensionData(extensionDataElement) + : null; + Dictionary? unknownProperties = null; + foreach (JsonProperty property in element.Value.EnumerateObject()) + { + if (property.NameEquals(SchemaVersionPropertyName) || + property.NameEquals(DataPropertyName) || + property.NameEquals(ExtensionDataPropertyName)) + { + continue; + } + + unknownProperties ??= []; + unknownProperties[property.Name] = property.Value.Clone(); + } return new DurableAgentState { - SchemaVersion = schemaVersion.ToString(), - Data = data ?? new DurableAgentStateData() + SchemaVersion = schemaVersionText!, + Data = data, + ExtensionData = extensionData, + UnknownProperties = unknownProperties, }; } /// public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options) { + WriteValue(writer, value, allowRevisedSchema: 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(); writer.WritePropertyName(SchemaVersionPropertyName); writer.WriteStringValue(value.SchemaVersion); @@ -66,6 +154,798 @@ 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.Object) + { + throw new JsonException("The durable agent state 'extensionData' property must be an object."); + } + + return element.EnumerateObject().ToDictionary( + property => property.Name, + property => property.Value.Clone()); + } + + private static void WriteExtensionData( + Utf8JsonWriter writer, + IDictionary extensionData) + { + writer.WriteStartObject(); + foreach ((string propertyName, JsonElement propertyValue) in extensionData) + { + writer.WritePropertyName(propertyName); + propertyValue.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + private static void ValidateRevisedLayout(JsonElement dataElement) + { + if (dataElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The revised durable agent state 'data' property must be an object."); + } + + foreach (string requiredProperty in new[] + { + "conversationHistory", + "terminalResults", + "completionReceipts", + }) + { + if (!dataElement.TryGetProperty(requiredProperty, out _)) + { + throw new InvalidOperationException( + $"The revised durable agent state is missing the 'data.{requiredProperty}' property."); + } + } + + ValidateUniqueObjectKeys(dataElement.GetProperty("terminalResults"), "terminalResults"); + ValidateUniqueObjectKeys(dataElement.GetProperty("completionReceipts"), "completionReceipts"); + ValidateIngestionAndTruncation(dataElement); + ValidateTranscript(dataElement.GetProperty("conversationHistory")); + ValidateTerminalMessages(dataElement.GetProperty("terminalResults")); + } + + private static void ValidateOpaqueSession(JsonElement dataElement) + { + if (dataElement.ValueKind == JsonValueKind.Object && + dataElement.TryGetProperty("session", out JsonElement session) && + session.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.session' property must be a JSON object."); + } + } + + private static void RejectLegacyRevisedFields(JsonElement dataElement) + { + if (dataElement.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (string propertyName in new[] + { + "terminalResults", + "completionReceipts", + "historyBinding", + }) + { + if (dataElement.TryGetProperty(propertyName, out _)) + { + throw new InvalidOperationException( + $"The durable agent state 'data.{propertyName}' property requires schema version 2.0.0."); + } + } + + ValidateIngestionAndTruncation(dataElement); + } + + private static void ValidateLegacyTranscript(JsonElement dataElement) + { + if (!dataElement.TryGetProperty("conversationHistory", out JsonElement history)) + { + return; + } + + if (history.ValueKind != JsonValueKind.Array) + { + throw new JsonException( + "The legacy durable agent state 'data.conversationHistory' property must be an array."); + } + + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent conversation history cannot contain non-object entries."); + } + + if (!entry.TryGetProperty("messages", out JsonElement messages)) + { + continue; + } + + if (messages.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "Legacy durable agent entry messages must be an array when present."); + } + + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent entry messages cannot contain non-object values."); + } + + string? roleText = + message.TryGetProperty("role", out JsonElement role) && + role.ValueKind == JsonValueKind.String + ? role.GetString() + : null; + if (roleText is not ("user" or "assistant" or "system" or "tool")) + { + throw new InvalidOperationException( + $"The legacy durable agent state message role '{roleText}' is not supported."); + } + + if (!message.TryGetProperty("contents", out JsonElement contents)) + { + continue; + } + + if (contents.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "Legacy durable agent message contents must be an array when present."); + } + + foreach (JsonElement content in contents.EnumerateArray()) + { + if (content.ValueKind != JsonValueKind.Object || + !content.TryGetProperty("$type", out JsonElement contentType) || + contentType.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "Legacy durable agent message contents require object values with string discriminators."); + } + + if (contentType.ValueEquals("functionCall") && + content.TryGetProperty("arguments", out JsonElement arguments) && + arguments.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent function-call arguments must be an object when present."); + } + + if (contentType.ValueEquals("uri") && + (!content.TryGetProperty("mediaType", out JsonElement mediaType) || + mediaType.ValueKind != JsonValueKind.String)) + { + throw new InvalidOperationException( + "Legacy durable agent URI content requires a string mediaType."); + } + + if (contentType.ValueEquals("usage") && + content.TryGetProperty("usage", out JsonElement contentUsage)) + { + if (contentUsage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Legacy durable agent usage content requires an object-valued usage property."); + } + + ValidateUsageObject(contentUsage, "message.contents.usage"); + } + + ValidateKnownContentFields(content, contentType.GetString()!); + } + } + } + } + + private static void ValidateIngestionAndTruncation(JsonElement dataElement) + { + if (dataElement.TryGetProperty("ingestedPositions", out JsonElement ingestedPositions)) + { + if (ingestedPositions.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + "The durable agent state 'data.ingestedPositions' property must be an object."); + } + + foreach (JsonProperty position in ingestedPositions.EnumerateObject()) + { + if (position.Value.ValueKind != JsonValueKind.Number || + !position.Value.TryGetInt32(out int value) || + value < 0) + { + throw new InvalidOperationException( + $"The durable agent ingestion position '{position.Name}' must be a non-negative Int32 value."); + } + } + } + + if (dataElement.TryGetProperty("truncation", out JsonElement truncation)) + { + if (truncation.ValueKind != JsonValueKind.Object || + !truncation.TryGetProperty("evictedMessageCount", out _) || + !truncation.TryGetProperty("firstEvictedAt", out _) || + !truncation.TryGetProperty("lastEvictedAt", out _)) + { + throw new InvalidOperationException( + "Durable agent truncation evidence requires evictedMessageCount, firstEvictedAt, and lastEvictedAt."); + } + } + } + + private static void ValidateDeclaredExtensionData(JsonElement root, JsonElement data) + { + RequireObjectWhenPresent(root, ExtensionDataPropertyName, "extensionData"); + RequireObjectWhenPresent(data, ExtensionDataPropertyName, "data.extensionData"); + + if (data.TryGetProperty("conversationHistory", out JsonElement history) && + history.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireObjectWhenPresent(entry, ExtensionDataPropertyName, "conversationHistory.extensionData"); + string? entryType = entry.TryGetProperty("$type", out JsonElement typeElement) && + typeElement.ValueKind == JsonValueKind.String + ? typeElement.GetString() + : null; + if (entryType is "response" or "errorResponse" && + entry.TryGetProperty("usage", out JsonElement usage) && + usage.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent(usage, ExtensionDataPropertyName, "conversationHistory.usage.extensionData"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages) && + messages.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + message, + ExtensionDataPropertyName, + "conversationHistory.messages.extensionData"); + } + } + } + } + } + + if (data.TryGetProperty("terminalResults", out JsonElement terminalResults) && + terminalResults.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (result.Value.TryGetProperty("resultExpiresAt", out JsonElement resultExpiresAt) && + resultExpiresAt.ValueKind != JsonValueKind.String) + { + throw new JsonException( + $"Durable agent terminal result '{result.Name}' resultExpiresAt must be a string when present."); + } + + if (result.Value.TryGetProperty("error", out JsonElement error) && + error.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + $"Durable agent terminal result '{result.Name}' error must be an object when present."); + } + + if (result.Value.TryGetProperty("response", out JsonElement response) && + response.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + response, + ExtensionDataPropertyName, + $"terminalResults.{result.Name}.response.extensionData"); + foreach (string propertyName in new[] + { + "createdAt", + "responseId", + "agentId", + "finishReason", + "continuationToken", + }) + { + RequireStringWhenPresent( + response, + propertyName, + $"terminalResults.{result.Name}.response.{propertyName}"); + } + + if (response.TryGetProperty("usage", out JsonElement usage) && + usage.ValueKind == JsonValueKind.Object) + { + RequireObjectWhenPresent( + usage, + ExtensionDataPropertyName, + $"terminalResults.{result.Name}.response.usage.extensionData"); + } + } + } + } + + if (data.TryGetProperty("completionReceipts", out JsonElement receipts) && + receipts.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty receipt in receipts.EnumerateObject()) + { + foreach (string propertyName in new[] { "resultExpiresAt", "resultUnavailableAt" }) + { + RequireStringWhenPresent( + receipt.Value, + propertyName, + $"completionReceipts.{receipt.Name}.{propertyName}"); + } + } + } + } + + private static void ValidateKnownFieldShapes(JsonElement data) + { + RequireDateTimeWhenPresent( + data, + "expirationTimeUtc", + "data.expirationTimeUtc", + allowNull: true); + + if (data.TryGetProperty("conversationHistory", out JsonElement history) && + history.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in history.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireDateTimeWhenPresent(entry, "createdAt", "conversationHistory.createdAt"); + RequireStringWhenPresent(entry, "correlationId", "conversationHistory.correlationId"); + string? entryType = entry.TryGetProperty("$type", out JsonElement typeElement) && + typeElement.ValueKind == JsonValueKind.String + ? typeElement.GetString() + : null; + if (entryType == "request") + { + RequireStringWhenPresent(entry, "orchestrationId", "conversationHistory.orchestrationId"); + RequireStringWhenPresent(entry, "responseType", "conversationHistory.responseType"); + RequireObjectWhenPresent(entry, "responseSchema", "conversationHistory.responseSchema"); + } + else if (entryType is "response" or "errorResponse") + { + ValidateUsageWhenPresent(entry, "usage", "conversationHistory.usage"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages) && + messages.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + continue; + } + + RequireStringWhenPresent(message, "authorName", "conversationHistory.messages.authorName"); + RequireDateTimeWhenPresent( + message, + "createdAt", + "conversationHistory.messages.createdAt"); + RequireStringWhenPresent(message, "messageId", "conversationHistory.messages.messageId"); + } + } + } + } + + if (data.TryGetProperty("terminalResults", out JsonElement terminalResults) && + terminalResults.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (result.Value.TryGetProperty("response", out JsonElement response) && + response.ValueKind == JsonValueKind.Object) + { + RequireDateTimeWhenPresent( + result.Value, + "completedAt", + $"terminalResults.{result.Name}.completedAt"); + RequireDateTimeWhenPresent( + result.Value, + "resultExpiresAt", + $"terminalResults.{result.Name}.resultExpiresAt"); + RequireDateTimeWhenPresent( + response, + "createdAt", + $"terminalResults.{result.Name}.response.createdAt"); + ValidateUsageWhenPresent( + response, + "usage", + $"terminalResults.{result.Name}.response.usage"); + } + } + } + + if (data.TryGetProperty("completionReceipts", out JsonElement receipts) && + receipts.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty receipt in receipts.EnumerateObject()) + { + RequireDateTimeWhenPresent( + receipt.Value, + "completedAt", + $"completionReceipts.{receipt.Name}.completedAt"); + RequireDateTimeWhenPresent( + receipt.Value, + "resultExpiresAt", + $"completionReceipts.{receipt.Name}.resultExpiresAt"); + RequireDateTimeWhenPresent( + receipt.Value, + "resultUnavailableAt", + $"completionReceipts.{receipt.Name}.resultUnavailableAt"); + } + } + + if (data.TryGetProperty("truncation", out JsonElement truncation) && + truncation.ValueKind == JsonValueKind.Object) + { + RequireDateTimeWhenPresent(truncation, "firstEvictedAt", "data.truncation.firstEvictedAt"); + RequireDateTimeWhenPresent(truncation, "lastEvictedAt", "data.truncation.lastEvictedAt"); + } + } + + private static void ValidateUsageWhenPresent(JsonElement parent, string propertyName, string path) + { + if (!parent.TryGetProperty(propertyName, out JsonElement usage)) + { + return; + } + + if (usage.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The durable agent state '{path}' property must be an object."); + } + + ValidateUsageObject(usage, path); + } + + private static void ValidateUsageObject(JsonElement usage, string path) + { + foreach (string countName in new[] { "inputTokenCount", "outputTokenCount", "totalTokenCount" }) + { + if (usage.TryGetProperty(countName, out JsonElement count) && + (count.ValueKind != JsonValueKind.Number || !count.TryGetInt64(out _))) + { + throw new JsonException( + $"The durable agent state '{path}.{countName}' property must be an Int64 value."); + } + } + + RequireObjectWhenPresent(usage, ExtensionDataPropertyName, $"{path}.extensionData"); + } + + private static void RequireObjectWhenPresent(JsonElement parent, string propertyName, string path) + { + if (parent.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The durable agent state '{path}' property must be an object."); + } + } + + private static void RequireStringWhenPresent(JsonElement parent, string propertyName, string path) + { + if (parent.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.String) + { + throw new JsonException($"The durable agent state '{path}' property must be a string."); + } + } + + private static void RequireDateTimeWhenPresent( + JsonElement parent, + string propertyName, + string path, + bool allowNull = false) + { + if (!parent.TryGetProperty(propertyName, out JsonElement value)) + { + return; + } + + if (allowNull && value.ValueKind == JsonValueKind.Null) + { + return; + } + + if (value.ValueKind != JsonValueKind.String || + !IsOffsetRfc3339(value.GetString())) + { + throw new JsonException( + $"The durable agent state '{path}' property must be an RFC 3339 date-time with an explicit offset."); + } + } + + private static bool IsOffsetRfc3339(string? value) + { + if (string.IsNullOrEmpty(value) || + !s_rfc3339Pattern.IsMatch(value) || + !DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _)) + { + return false; + } + + return value.EndsWith('Z') || + (value.Length >= 6 && + value[^6] is '+' or '-' && + value[^3] == ':'); + } + + private static void ValidateUniqueObjectKeys(JsonElement element, string propertyName) + { + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The revised durable agent state 'data.{propertyName}' property must be an object."); + } + + HashSet keys = new(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!keys.Add(property.Name)) + { + throw new InvalidOperationException( + $"The revised durable agent state 'data.{propertyName}' property contains duplicate correlation ID '{property.Name}'."); + } + } + } + + private static void ValidateTerminalMessages(JsonElement terminalResults) + { + foreach (JsonProperty result in terminalResults.EnumerateObject()) + { + if (!result.Value.TryGetProperty("response", out JsonElement response) || + !response.TryGetProperty("messages", out JsonElement messages)) + { + throw new InvalidOperationException( + $"Durable agent terminal result '{result.Name}' requires a response messages collection."); + } + + ValidateMessageArray(messages, $"terminal result '{result.Name}'"); + } + } + + private static void ValidateTranscript(JsonElement conversationHistory) + { + if (conversationHistory.ValueKind != JsonValueKind.Array) + { + throw new JsonException( + "The revised durable agent state 'data.conversationHistory' property must be an array."); + } + + foreach (JsonElement entry in conversationHistory.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object || + !entry.TryGetProperty("$type", out JsonElement typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + continue; + } + + string? entryType = typeElement.GetString(); + bool hasCorrelation = entry.TryGetProperty("correlationId", out JsonElement correlation); + if (entryType == "compaction" && hasCorrelation) + { + throw new InvalidOperationException( + "A revised durable agent compaction entry cannot declare correlationId."); + } + + if (entryType is "request" or "response" or "errorResponse" && hasCorrelation) + { + if (correlation.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "A revised durable agent transcript correlationId must be a string when present."); + } + + DurableAgentStateContract.ValidateIdentifier( + correlation.GetString(), + "conversationHistory.correlationId"); + } + + if (entry.TryGetProperty("messages", out JsonElement messages)) + { + ValidateMessageArray(messages, "conversationHistory"); + } + } + } + + private static void ValidateMessageArray(JsonElement messages, string location) + { + if (messages.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent {location} messages must be an array."); + } + + foreach (JsonElement message in messages.EnumerateArray()) + { + if (message.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Durable agent {location} contains a non-object message."); + } + + RequireStringWhenPresent(message, "authorName", $"{location}.authorName"); + RequireDateTimeWhenPresent(message, "createdAt", $"{location}.createdAt"); + RequireStringWhenPresent(message, "messageId", $"{location}.messageId"); + RequireObjectWhenPresent(message, ExtensionDataPropertyName, $"{location}.extensionData"); + + if (!message.TryGetProperty("contents", out JsonElement contents)) + { + continue; + } + + if (contents.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Durable agent {location} contains a non-array message contents property."); + } + + foreach (JsonElement content in contents.EnumerateArray()) + { + if (content.ValueKind != JsonValueKind.Object || + !content.TryGetProperty("$type", out JsonElement contentType) || + contentType.ValueKind != JsonValueKind.String) + { + continue; + } + + if (contentType.ValueEquals("functionCall") && + content.TryGetProperty("arguments", out JsonElement arguments) && + arguments.ValueKind is not JsonValueKind.Object and not JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent function-call arguments must be an object or string when present."); + } + + if (contentType.ValueEquals("uri") && + content.TryGetProperty("mediaType", out JsonElement mediaType) && + mediaType.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "Durable agent URI mediaType must be a string when present."); + } + + if (contentType.ValueEquals("usage") && + content.TryGetProperty("usage", out JsonElement contentUsage)) + { + if (contentUsage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Durable agent usage content requires an object-valued usage property."); + } + + ValidateUsageObject(contentUsage, "message.contents.usage"); + } + + ValidateKnownContentFields(content, contentType.GetString()!); + } + } + } + + private static void ValidateKnownContentFields(JsonElement content, string contentType) + { + switch (contentType) + { + case "data": + RequireString(content, "uri", contentType); + OptionalString(content, "mediaType", contentType); + break; + case "error": + OptionalString(content, "message", contentType); + OptionalString(content, "errorCode", contentType); + break; + case "functionCall": + RequireString(content, "callId", contentType); + RequireString(content, "name", contentType); + break; + case "functionResult": + RequireString(content, "callId", contentType); + break; + case "hostedFile": + RequireString(content, "fileId", contentType); + break; + case "hostedVectorStore": + RequireString(content, "vectorStoreId", contentType); + break; + case "text": + RequireString(content, "text", contentType); + break; + case "reasoning": + OptionalString(content, "text", contentType); + break; + case "uri": + RequireString(content, "uri", contentType); + break; + case "usage": + if (!content.TryGetProperty("usage", out JsonElement usage) || + usage.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Durable agent usage content requires an object-valued usage property."); + } + + break; + case "unknown": + if (!content.TryGetProperty("content", out _)) + { + throw new InvalidOperationException( + "Durable agent unknown content requires the original content value."); + } + + break; + } + } + + private static void RequireString(JsonElement element, string propertyName, string contentType) + { + if (!element.TryGetProperty(propertyName, out JsonElement value) || + value.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Durable agent '{contentType}' content requires string property '{propertyName}'."); + } + } + + private static void OptionalString(JsonElement element, string propertyName, string contentType) + { + if (element.TryGetProperty(propertyName, out JsonElement value) && + value.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Durable agent '{contentType}' content property '{propertyName}' must be a string when present."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index 294453c..1bea283 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -25,11 +26,35 @@ internal sealed class DurableAgentStateMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? CreatedAt { get; init; } + /// + /// Gets the stable message identifier. + /// + [JsonPropertyName("messageId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MessageId { get; set; } + + /// + /// Gets producer-defined message values from the schema's declared extensionData field. + /// + /// + /// The CLR name mirrors so conversion does not invent a + /// second metadata vocabulary. The wire name remains extensionData for cross-language schema + /// compatibility. This declared field is distinct from , which captures + /// undeclared future members adjacent to the message's known JSON fields. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? AdditionalProperties { get; init; } + /// /// Gets the contents of this message. /// [JsonPropertyName("contents")] - public IReadOnlyList Contents { get; init; } = []; + public IReadOnlyList Contents + { + get; + init => field = value ?? []; + } = []; /// /// Gets the role of the message sender (e.g., "user", "assistant", "system"). @@ -38,24 +63,70 @@ internal sealed class DurableAgentStateMessage public required string Role { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets undeclared future message properties that appear beside the schema's known fields. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . /// /// The to convert. + /// The stable identifier to use when the message does not already have one. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original message. - public static DurableAgentStateMessage FromChatMessage(ChatMessage message) + public static DurableAgentStateMessage FromChatMessage( + ChatMessage message, + string? generatedMessageId = null, + ILogger? logger = null) + => FromChatMessage(message, generatedMessageId, requireJsonSafeMetadata: false, logger); + + 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) { + string role = message.Role.ToString(); + if (!requireJsonSafeMetadata && + role is not ("user" or "assistant" or "system" or "tool")) + { + throw new InvalidOperationException( + $"The legacy durable agent state cannot persist message role '{role}'."); + } + + Dictionary? additionalProperties = null; + if (message.AdditionalProperties is not null) + { + foreach ((string key, object? value) in message.AdditionalProperties) + { + JsonElement element = requireJsonSafeMetadata + ? DurableAgentStateTerminalResponse.ConvertMetadata(value, key) + : JsonSerializer.SerializeToElement( + value, + DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + additionalProperties ??= []; + additionalProperties[key] = element; + } + } + return new DurableAgentStateMessage() { CreatedAt = message.CreatedAt, AuthorName = message.AuthorName, - Role = message.Role.ToString(), - Contents = message.Contents.Select(DurableAgentStateContent.FromAIContent).ToList() + MessageId = message.MessageId ?? generatedMessageId, + AdditionalProperties = additionalProperties, + Role = role, + Contents = message.Contents.Select(content => + requireJsonSafeMetadata + ? DurableAgentStateContent.FromAIContentV2(content, logger) + : DurableAgentStateContent.FromAIContent(content, logger)).ToList() }; } @@ -65,12 +136,44 @@ public static DurableAgentStateMessage FromChatMessage(ChatMessage message) /// A representing this message. public ChatMessage ToChatMessage() { + AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null + ? null + : new AdditionalPropertiesDictionary( + this.AdditionalProperties.Select(pair => + new KeyValuePair(pair.Key, pair.Value))); + return new ChatMessage() { CreatedAt = this.CreatedAt, AuthorName = this.AuthorName, + MessageId = this.MessageId, + AdditionalProperties = additionalProperties, Contents = this.Contents.Select(c => c.ToAIContent()).ToList(), Role = new(this.Role) }; } + + public void ValidateV2() + { + if (this.Role is not "user" and + not "assistant" and + not "system" and + not "developer" and + not "tool") + { + throw new InvalidOperationException( + $"The durable agent state message role '{this.Role}' is not supported."); + } + + if (this.Contents.Any(static content => content is null)) + { + throw new InvalidOperationException( + "A durable agent state message cannot contain null content entries."); + } + + foreach (DurableAgentStateContent content in this.Contents) + { + content.ValidateV2(); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs new file mode 100644 index 0000000..46e72c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Assigns deterministic identities to durable messages that predate schema 1.2. +/// +internal static class DurableAgentStateMessageIdentity +{ + public static void EnsureMessageIds(IEnumerable history) + { + foreach (DurableAgentStateEntry entry in history) + { + string entryType = entry switch + { + DurableAgentStateErrorResponse => "errorResponse", + DurableAgentStateRequest => "request", + DurableAgentStateResponse => "response", + DurableAgentStateCompaction => "compaction", + _ => throw new InvalidOperationException( + $"Unsupported durable agent state entry type '{entry.GetType()}'."), + }; + + for (int index = 0; index < entry.Messages.Count; index++) + { + DurableAgentStateMessage message = entry.Messages[index]; + if (message.MessageId is null) + { + message.MessageId = Create( + entryType, + entry.CorrelationId, + entry.CreatedAt ?? default, + index); + } + } + } + } + + public static string Create( + string entryType, + string? correlationId, + DateTimeOffset createdAt, + int storedIndex) + { + string scope = string.IsNullOrEmpty(correlationId) + ? FormatPythonIsoTimestamp(createdAt) + : correlationId; + return $"durable_{entryType}_{scope}_{storedIndex}"; + } + + internal static string FormatPythonIsoTimestamp(DateTimeOffset timestamp) + { + long microseconds = timestamp.Ticks % TimeSpan.TicksPerSecond / 10; + string fraction = microseconds == 0 + ? string.Empty + : $".{microseconds.ToString("D6", CultureInfo.InvariantCulture)}"; + return string.Concat( + timestamp.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture), + fraction, + timestamp.ToString("zzz", CultureInfo.InvariantCulture)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/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..c664081 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Numerics; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Centralizes strict parsing, supported-major validation, and ordering for durable state versions. +/// +internal readonly record struct DurableAgentStateSchemaVersion(BigInteger Major, BigInteger Minor, BigInteger Patch) + : IComparable +{ + private static readonly HashSet s_supportedVersions = + [ + "1.0.0", + "1.1.0", + DurableAgentState.CurrentSchemaVersion, + DurableAgentState.RevisedSchemaVersion, + ]; + + /// + /// Parses and validates a supported durable agent state schema version. + /// + public static DurableAgentStateSchemaVersion ParseSupported(string? value) + { + if (value is null || !s_supportedVersions.Contains(value)) + { + throw new InvalidOperationException($"The durable agent state schema version '{value}' is not supported."); + } + + _ = TryParse(value, out DurableAgentStateSchemaVersion version); + return version; + } + + /// + /// Parses the schema's strict numeric major.minor.patch grammar. + /// + public static bool TryParse(string? value, out DurableAgentStateSchemaVersion version) + { + version = default; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + ReadOnlySpan remaining = value.AsSpan(); + if (!TryReadComponent(ref remaining, out BigInteger major) || + !TryReadComponent(ref remaining, out BigInteger minor) || + !TryReadFinalComponent(remaining, out BigInteger patch)) + { + return false; + } + + version = new(major, minor, patch); + return true; + } + + /// + public int CompareTo(DurableAgentStateSchemaVersion other) + { + int majorComparison = this.Major.CompareTo(other.Major); + if (majorComparison != 0) + { + return majorComparison; + } + + int minorComparison = this.Minor.CompareTo(other.Minor); + return minorComparison != 0 + ? minorComparison + : this.Patch.CompareTo(other.Patch); + } + + private static bool TryReadComponent(ref ReadOnlySpan value, out BigInteger component) + { + int separatorIndex = value.IndexOf('.'); + if (separatorIndex <= 0 || + !TryParseNumericIdentifier(value[..separatorIndex], out component)) + { + component = default; + return false; + } + + value = value[(separatorIndex + 1)..]; + return true; + } + + private static bool TryReadFinalComponent(ReadOnlySpan value, out BigInteger component) + { + component = default; + return value.IndexOf('.') < 0 && TryParseNumericIdentifier(value, out component); + } + + private static bool TryParseNumericIdentifier(ReadOnlySpan value, out BigInteger component) + { + component = 0; + if (value.IsEmpty || (value.Length > 1 && value[0] == '0')) + { + return false; + } + + foreach (char character in value) + { + if (character is < '0' or > '9') + { + return false; + } + + component = (component * 10) + (character - '0'); + } + + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs new file mode 100644 index 0000000..57953bf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalError.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// JSON-safe failure metadata for a terminal result. +/// +internal sealed class DurableAgentStateTerminalError +{ + [JsonPropertyName("code")] + public required string Code { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Details + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate() + { + DurableAgentStateContract.ValidateIdentifier(this.Code, "terminalResults.error.code"); + if (string.IsNullOrWhiteSpace(this.Message) || + this.Message.EnumerateRunes() + .Take(DurableAgentStateContract.MaxMetadataStringLength + 1) + .Count() > DurableAgentStateContract.MaxMetadataStringLength) + { + throw new InvalidOperationException( + $"The durable agent terminal error message must contain a non-whitespace character and be at most {DurableAgentStateContract.MaxMetadataStringLength} Unicode 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..2c18bcb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResponse.cs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.State; + +#pragma warning disable MEAI001 // ResponseContinuationToken is part of the AgentResponse contract captured here. + +/// +/// Immutable, JSON-safe projection of the fields consumed from an . +/// +internal sealed class DurableAgentStateTerminalResponse +{ + [JsonPropertyName("messages")] + public IReadOnlyList Messages + { + get; + init => field = value ?? []; + } = []; + + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateUsage? Usage { get; init; } + + [JsonPropertyName("createdAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } + + /// + /// Gets an optional caller-visible JSON result independent of the response messages. + /// + /// + /// means the wire property was absent. All other JSON values, + /// including explicit null, false, zero, empty strings, arrays, and objects, are present values. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Value + { + get; + init => field = value.ValueKind == JsonValueKind.Undefined ? default : value.Clone(); + } + + [JsonPropertyName("responseId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResponseId { get; init; } + + [JsonPropertyName("agentId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AgentId { get; init; } + + [JsonPropertyName("finishReason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; init; } + + [JsonPropertyName("continuationToken")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContinuationToken { get; init; } + + /// + /// Gets JSON-safe producer-defined values from the declared wire-level extensionData field. + /// + /// + /// The CLR name mirrors . Keeping that name makes the + /// projection boundary explicit while preserves the shared + /// schema name. This field is not the same as , which contains + /// undeclared future JSON members. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? AdditionalProperties { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public static DurableAgentStateTerminalResponse FromResponse( + AgentResponse response, + string correlationId, + DateTimeOffset completedAt, + JsonElement structuredValue = default, + ILogger? logger = null) + { + Dictionary? additionalProperties = null; + if (response.AdditionalProperties is not null) + { + foreach ((string key, object? value) in response.AdditionalProperties) + { + additionalProperties ??= []; + additionalProperties[key] = ConvertMetadata(value, key); + } + } + + return new() + { + Messages = response.Messages + .Select((message, index) => DurableAgentStateMessage.FromTerminalChatMessage( + message, + DurableAgentStateMessageIdentity.Create("result", correlationId, completedAt, index), + logger)) + .ToList(), + Usage = DurableAgentStateUsage.FromUsage(response.Usage), + CreatedAt = response.CreatedAt, + Value = structuredValue, + ResponseId = response.ResponseId, + AgentId = response.AgentId, + FinishReason = response.FinishReason?.Value, + ContinuationToken = response.ContinuationToken is null + ? null + : Convert.ToBase64String(response.ContinuationToken.ToBytes().Span), + AdditionalProperties = additionalProperties, + }; + } + + public AgentResponse ToResponse() + { + 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."); + } + + message.ValidateV2(); + } + + ValidateOptionalIdentifier(this.ResponseId, "terminalResults.response.responseId"); + ValidateOptionalIdentifier(this.AgentId, "terminalResults.response.agentId"); + ValidateOptionalIdentifier(this.FinishReason, "terminalResults.response.finishReason"); + + if (this.ContinuationToken is not null) + { + if (this.ContinuationToken.Length > DurableAgentStateContract.MaxMetadataStringLength) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token is too large."); + } + + try + { + byte[] decoded = Convert.FromBase64String(this.ContinuationToken); + if (!string.Equals( + this.ContinuationToken, + Convert.ToBase64String(decoded), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token must use canonical base64 encoding."); + } + } + catch (FormatException exception) + { + throw new InvalidOperationException( + "The durable agent terminal response continuation token must be base64 encoded.", + exception); + } + } + + if (this.AdditionalProperties is not null) + { + foreach (string key in this.AdditionalProperties.Keys) + { + DurableAgentStateContract.ValidateIdentifier(key, "terminalResults.response.extensionData key"); + } + } + } + + private static void ValidateOptionalIdentifier(string? value, string propertyName) + { + if (value is not null) + { + DurableAgentStateContract.ValidateIdentifier(value, propertyName); + } + } + + internal static JsonElement ConvertMetadata(object? value, string propertyName) + { + switch (value) + { + case null: + return JsonSerializer.SerializeToElement( + value, + DurableAgentStateJsonContext.Default.Object); + case JsonElement jsonElement: + return jsonElement.Clone(); + case string text when text.Length <= DurableAgentStateContract.MaxMetadataStringLength: + return JsonSerializer.SerializeToElement( + text, + DurableAgentStateJsonContext.Default.String); + case bool boolean: + return JsonSerializer.SerializeToElement( + boolean, + DurableAgentStateJsonContext.Default.Boolean); + case int integer: + return JsonSerializer.SerializeToElement( + integer, + DurableAgentStateJsonContext.Default.Int32); + case long longInteger: + return JsonSerializer.SerializeToElement( + longInteger, + DurableAgentStateJsonContext.Default.Int64); + case double doubleValue when double.IsFinite(doubleValue): + return JsonSerializer.SerializeToElement( + doubleValue, + DurableAgentStateJsonContext.Default.Double); + case decimal decimalValue: + return JsonSerializer.SerializeToElement( + decimalValue, + DurableAgentStateJsonContext.Default.Decimal); + case DateTimeOffset dateTimeOffset: + return JsonSerializer.SerializeToElement( + dateTimeOffset, + DurableAgentStateJsonContext.Default.DateTimeOffset); + default: + throw new InvalidOperationException( + $"The AgentResponse metadata property '{propertyName}' has unsupported runtime type '{value?.GetType()}'."); + } + } + +#pragma warning restore MEAI001 +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs new file mode 100644 index 0000000..58dce17 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTerminalResult.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Immutable terminal result envelope detached from evictable conversation history. +/// +/// +/// A later runtime layer must commit this result and its matching receipt in the same durable entity +/// operation as session continuation, ingestion bookkeeping, entity-local transcript, TTL, optional +/// binding, and other local control state. This DTO performs no commit or delivery behavior. +/// +internal sealed class DurableAgentStateTerminalResult +{ + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + [JsonPropertyName("outcome")] + public required string Outcome { get; init; } + + [JsonPropertyName("completedAt")] + public required DateTimeOffset CompletedAt { get; init; } + + [JsonPropertyName("resultExpiresAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? ResultExpiresAt { get; init; } + + [JsonPropertyName("response")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTerminalResponse? Response { get; init; } + + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTerminalError? Error { get; init; } + + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public static DurableAgentStateTerminalResult FromResponse( + string correlationId, + AgentResponse response, + DateTimeOffset completedAt, + DateTimeOffset? resultExpiresAt = null, + JsonElement structuredValue = default, + ILogger? logger = null) + { + DurableAgentStateContract.ValidateIdentifier(correlationId, "terminalResults.correlationId"); + return new() + { + CorrelationId = correlationId, + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultExpiresAt = resultExpiresAt, + Response = DurableAgentStateTerminalResponse.FromResponse( + response, + correlationId, + completedAt, + structuredValue, + logger), + }; + } + + public void Validate(string dictionaryKey) + { + DurableAgentStateContract.ValidateIdentifier(dictionaryKey, "terminalResults key"); + DurableAgentStateContract.ValidateIdentifier(this.CorrelationId, "terminalResults.correlationId"); + if (!string.Equals(dictionaryKey, this.CorrelationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The durable agent state terminal result key '{dictionaryKey}' does not match correlation ID '{this.CorrelationId}'."); + } + + if (this.Outcome is not DurableAgentStateCompletionReceipt.SucceededOutcome and + not DurableAgentStateCompletionReceipt.FailedOutcome) + { + throw new InvalidOperationException( + $"The durable agent state terminal outcome '{this.Outcome}' is not supported."); + } + + if (this.CompletedAt == default) + { + throw new InvalidOperationException( + "A durable agent terminal result requires a completion timestamp."); + } + + if (this.Response is null) + { + throw new InvalidOperationException( + "A durable agent terminal result must contain a response payload."); + } + + if (this.Outcome == DurableAgentStateCompletionReceipt.SucceededOutcome && this.Error is not null) + { + throw new InvalidOperationException( + "A successful durable agent terminal result cannot contain error metadata."); + } + + if (this.Outcome == DurableAgentStateCompletionReceipt.FailedOutcome && this.Error is null) + { + throw new InvalidOperationException( + "A failed durable agent terminal result must contain error metadata."); + } + + if (this.ResultExpiresAt < this.CompletedAt) + { + throw new InvalidOperationException( + "The durable agent terminal result expiry cannot precede completion."); + } + + this.Response.Validate(); + this.Error?.Validate(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs new file mode 100644 index 0000000..2769ae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Bounded diagnostic evidence that durable transcript messages were removed. +/// +/// +/// This object survives the removed entries so a persisted gap is not mistaken for history that never +/// existed. It does not contain model context and does not establish request delivery or completion. +/// +internal sealed class DurableAgentStateTruncation +{ + /// + /// Gets or sets the cumulative number of transcript messages known to have been removed. + /// + [JsonPropertyName("evictedMessageCount")] + public int EvictedMessageCount { get; set; } + + /// + /// Gets or sets when transcript removal was first recorded. + /// + [JsonPropertyName("firstEvictedAt")] + public DateTimeOffset FirstEvictedAt { get; set; } + + /// + /// Gets or sets when transcript removal was most recently recorded. + /// + [JsonPropertyName("lastEvictedAt")] + public DateTimeOffset LastEvictedAt { get; set; } + + /// + /// Gets undeclared future truncation evidence fields. + /// + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + public void Validate() + { + if (this.EvictedMessageCount < 1 || + this.FirstEvictedAt == default || + this.LastEvictedAt == default || + this.LastEvictedAt < this.FirstEvictedAt) + { + throw new InvalidOperationException( + "Durable agent truncation evidence requires a positive count and ordered first/latest timestamps."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs index 00a180b..15b4f00 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,22 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// 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"; + private const string RawRepresentationPropertyName = "rawRepresentation"; + 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(); + /// /// Gets the serialized unknown content. /// @@ -21,23 +41,683 @@ 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, + [MarkerPropertyName] = DurableEnvelopeMarker, + [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(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) || + 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, + MarkerPropertyName, + VersionPropertyName, + AnnotationsPropertyName, + AdditionalPropertiesPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAnnotations(envelope, out List? annotations) || + !TryReadAdditionalProperties( + envelope, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(envelope, out object? rawRepresentation) || + !HasValidOmissions(envelope)) + { + return false; + } + + content = new AIContent + { + Annotations = annotations, + AdditionalProperties = additionalProperties, + RawRepresentation = rawRepresentation, + }; + return true; + } + + private static bool TryReadAnnotations( + JsonElement envelope, + out List? annotations) + { + annotations = null; + if (!envelope.TryGetProperty(AnnotationsPropertyName, out JsonElement annotationsElement)) + { + return true; + } + + if (annotationsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + List result = []; + JsonTypeInfo regionTypeInfo = + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AnnotatedRegion)); + foreach (JsonElement annotationElement in annotationsElement.EnumerateArray()) + { + if (annotationElement.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + annotationElement, + AdditionalPropertiesPropertyName, + AnnotatedRegionsPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAdditionalProperties( + annotationElement, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(annotationElement, out object? rawRepresentation) || + !HasValidOmissions(annotationElement)) + { + return false; + } + + List? regions = null; + if (annotationElement.TryGetProperty( + AnnotatedRegionsPropertyName, + out JsonElement regionsElement)) + { + if (regionsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + regions = []; + foreach (JsonElement regionElement in regionsElement.EnumerateArray()) + { + try + { + if (regionElement.Deserialize(regionTypeInfo) is not AnnotatedRegion region) + { + return false; + } + + regions.Add(region); + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + return false; + } + } + } + + result.Add(new AIAnnotation + { + AdditionalProperties = additionalProperties, + AnnotatedRegions = regions, + RawRepresentation = rawRepresentation, + }); + } + + annotations = result; + return true; + } + + private static bool TryReadAdditionalProperties( + JsonElement envelope, + out AdditionalPropertiesDictionary? additionalProperties) + { + additionalProperties = null; + if (!envelope.TryGetProperty( + AdditionalPropertiesPropertyName, + out JsonElement additionalPropertiesElement)) + { + return true; + } + + if (additionalPropertiesElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + AdditionalPropertiesDictionary result = []; + foreach (JsonProperty property in additionalPropertiesElement.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + additionalProperties = result; + return true; + } + + private static bool TryReadRawRepresentation( + JsonElement envelope, + out object? rawRepresentation) + { + rawRepresentation = null; + if (envelope.TryGetProperty( + RawRepresentationPropertyName, + out JsonElement rawRepresentationElement)) + { + rawRepresentation = rawRepresentationElement.Clone(); + } + + return true; + } + + private static bool HasValidOmissions(JsonElement envelope) + { + if (!envelope.TryGetProperty(OmittedPropertyName, out JsonElement omittedElement)) + { + return true; + } + + if (omittedElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (JsonProperty property in omittedElement.EnumerateObject()) + { + if (property.Name is not ( + AnnotationsPropertyName or + AdditionalPropertiesPropertyName or + RawRepresentationPropertyName or + AnnotatedRegionsPropertyName) || + (property.Value.ValueKind != JsonValueKind.True && + property.Value.ValueKind != JsonValueKind.False && + (property.Value.ValueKind != JsonValueKind.Number || + !property.Value.TryGetInt32(out int count) || + count < 0))) + { + return false; + } + } + + return true; + } + + private static bool HasExactlyOneProperty(JsonElement element, string propertyName) + { + int count = 0; + foreach (JsonProperty property in element.EnumerateObject()) + { + count++; + if (!property.NameEquals(propertyName) || count > 1) + { + return false; + } + } + + return count == 1; + } + + private static bool HasOnlyProperties(JsonElement element, params string[] allowedNames) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!allowedNames.Contains(property.Name, StringComparer.Ordinal)) + { + return false; + } + } + + return true; + } + + private static AIContent CreateOpaqueAIContent(JsonElement content) + { + return new AIContent + { + RawRepresentation = content.Clone(), + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["content"] = content.Clone(), + }, + }; + } + + private static void LogSerializationFallback( + ILogger? logger, + object? value, + Exception exception) + { + if (logger is null) + { + return; + } + + try + { + logger.LogUnknownContentSerializationFallback( + value?.GetType().FullName ?? "null", + GetFailureCategory(exception)); + } + catch (Exception loggingException) when (IsRecoverableSerializationFailure(loggingException)) + { + } + } + + private static string GetFailureCategory(Exception exception) + { + return exception switch + { + ObjectDisposedException => "disposedValue", + JsonException => "invalidJson", + NotSupportedException => "unsupportedType", + InvalidOperationException => "invalidOperation", + ArgumentException => "invalidValue", + FormatException => "invalidFormat", + OverflowException => "numericOverflow", + IOException => "ioFailure", + _ => "customSerializationFailure", + }; + } + + private static bool IsRecoverableSerializationFailure(Exception exception) + { + if (exception is OperationCanceledException or + OutOfMemoryException or + StackOverflowException or + AccessViolationException or + AppDomainUnloadedException or + BadImageFormatException or + CannotUnloadAppDomainException or + InvalidProgramException or + SEHException) + { + return false; + } + + if (exception is AggregateException aggregateException) + { + return aggregateException.InnerExceptions.All(IsRecoverableSerializationFailure); + } - return content ?? throw new InvalidOperationException($"The content '{this.Content}' is not valid AI content."); + return exception.InnerException is null || + IsRecoverableSerializationFailure(exception.InnerException); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs index 8c6bbb8..3090212 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs @@ -20,7 +20,8 @@ internal sealed class DurableAgentStateUriContent : DurableAgentStateContent /// Gets the media type of the content. /// [JsonPropertyName("mediaType")] - public required string MediaType { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MediaType { get; init; } /// /// Creates a from a . @@ -39,6 +40,12 @@ public static DurableAgentStateUriContent FromUriContent(UriContent uriContent) /// public override AIContent ToAIContent() { + if (this.MediaType is null) + { + throw new InvalidOperationException( + "The current .NET UriContent contract cannot represent a URI without a media type."); + } + return new UriContent(this.Uri, this.MediaType); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs index 1b3714f..d5b6e6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs @@ -34,10 +34,17 @@ internal sealed class DurableAgentStateUsage public long? TotalTokenCount { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets provider-specific usage counts from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown usage properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . @@ -51,7 +58,12 @@ usage is not null { InputTokenCount = usage.InputTokenCount, OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount + TotalTokenCount = usage.TotalTokenCount, + ExtensionData = usage.AdditionalCounts?.ToDictionary( + pair => pair.Key, + pair => JsonSerializer.SerializeToElement( + pair.Value, + DurableAgentStateJsonContext.Default.Int64)), } : null; @@ -61,11 +73,31 @@ usage is not null /// A representing this usage. public UsageDetails ToUsageDetails() { + AdditionalPropertiesDictionary? additionalCounts = null; + foreach (IDictionary? values in new[] { this.ExtensionData, this.UnknownProperties }) + { + if (values is null) + { + continue; + } + + foreach ((string name, JsonElement value) in values) + { + if (value.ValueKind == JsonValueKind.Number && + value.TryGetInt64(out long count)) + { + additionalCounts ??= []; + additionalCounts[name] = count; + } + } + } + return new() { InputTokenCount = this.InputTokenCount, OutputTokenCount = this.OutputTokenCount, - TotalTokenCount = this.TotalTokenCount + TotalTokenCount = this.TotalTokenCount, + AdditionalCounts = additionalCounts, }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 58166f0..07335cd 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,75 @@ The serialized state contains a root `schemaVersion` property, which represents Some versioning considerations: -- Versions should use semver notation (e.g. `".."`) +- Versions use the strict numeric SemVer core grammar `".."`: exactly three + non-negative decimal components, with no leading zeroes except the single digit `0`. Prerelease + suffixes, build metadata, a `v` prefix, missing/extra components, and whitespace are rejected. - Durable agents should use the version property to determine how to deserialize that state and should not attempt to deserialize semver-incompatible versions - Newer versions of durable agents should strive to be compatible with older schema versions (e.g. new properties and objects should be optional) - Durable agents should preserve existing, but unrecognized, properties when serializing state +Schema version 1.2 adds optional message identity and extension metadata, opaque session state, workflow +`ingestedPositions`, and bounded truncation evidence. The .NET workflow path preserves but does not currently +populate `ingestedPositions`. Older 1.x state remains readable. `DurableAgentState.Clone()` promotes older +supported versions to 1.2 when a caller uses that write-clone path. Versions outside the exact contract +snapshots are rejected until compatibility is explicitly reviewed. 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. + +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. + +## Revised execution-state foundation + +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 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. +An `available` receipt requires a matching result; an `unavailable` receipt proves completion without a result +payload while retaining its outcome. Absence of a receipt means only that no terminal completion is recorded; +it does not distinguish an accepted pending request from an unknown identity. Optional `historyBinding` is an +opaque, separately versioned runtime profile. Non-relying consumers preserve it without interpreting any +nested field. Only a relying runtime may validate a profile it recognizes against trusted host configuration; +the shared contract defines no owner kind, provider key, default, or transition policy. + +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 +gating before a 2.0 producer is enabled; preserving unknown fields alone is not sufficient because an +unaware worker could ignore completion receipts and rerun completed work. + ## Sample State ```json { - "schemaVersion": "1.0.0", + "schemaVersion": "1.2.0", "data": { "conversationHistory": [ { diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index c548159..e10bb5e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -10,4 +10,22 @@ + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs index 2fda117..15eae74 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,496 @@ 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 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() + { + 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.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); + Assert.False(envelope.TryGetProperty(nameof(FutureContent.FutureValue), out _)); + + AIContent restored = Assert.IsType(roundTripped.ToAIContent()); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["providerFlag"]).GetBoolean()); + Assert.Equal( + "ref-1", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["citation"]).GetString()); + JsonElement rawRepresentation = Assert.IsType(restored.RawRepresentation); + Assert.Equal("future", rawRepresentation.GetProperty("kind").GetString()); + Assert.Equal(42, rawRepresentation.GetProperty("value").GetInt32()); + } + + [Fact] + public void UnknownContentOmitsUnsafeValuesAndPreservesSafeMetadata() + { + CyclicPayload cyclicPayload = new(); + cyclicPayload.Self = cyclicPayload; + JsonElement disposedElement; + using (JsonDocument disposedDocument = JsonDocument.Parse("""{"value":"disposed-secret"}""")) + { + disposedElement = disposedDocument.RootElement; + } + + using MemoryStream stream = new([1, 2, 3]); + CollectingLogger logger = new(); + FutureContent original = new() + { + RawRepresentation = new ThrowingGetterPayload(), + AdditionalProperties = new() + { + ["safeString"] = "kept", + ["safeObject"] = new { value = 42 }, + ["cyclic"] = cyclicPayload, + ["delegate"] = () => { }, + ["stream"] = stream, + ["disposedJson"] = disposedElement, + ["invalidNumber"] = double.NaN, + ["customConverter"] = new ThrowingConverterPayload(), + }, + Annotations = + [ + new AIAnnotation + { + RawRepresentation = disposedElement, + AdditionalProperties = new() + { + ["safeAnnotation"] = "annotation-kept", + ["badAnnotation"] = new ThrowingConverterPayload(), + }, + }, + ], + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original, logger)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + Messages = + [ + new DurableAgentStateMessage + { + Role = "assistant", + Contents = [stored], + }, + ], + }); + Exception? finalSerializationException = Record.Exception( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement envelope = document.RootElement.GetProperty("content") + .GetProperty("$microsoftAgentFrameworkDurableTask"); + JsonElement additionalProperties = envelope.GetProperty("additionalProperties"); + Assert.Equal("kept", additionalProperties.GetProperty("safeString").GetString()); + Assert.Equal(42, additionalProperties.GetProperty("safeObject").GetProperty("value").GetInt32()); + Assert.False(additionalProperties.TryGetProperty("cyclic", out _)); + Assert.False(additionalProperties.TryGetProperty("delegate", out _)); + Assert.False(additionalProperties.TryGetProperty("stream", out _)); + Assert.False(additionalProperties.TryGetProperty("disposedJson", out _)); + Assert.False(additionalProperties.TryGetProperty("invalidNumber", out _)); + Assert.False(additionalProperties.TryGetProperty("customConverter", out _)); + Assert.True(envelope.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + Assert.Equal(6, envelope.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + + JsonElement annotation = envelope.GetProperty("annotations")[0]; + Assert.Equal( + "annotation-kept", + annotation.GetProperty("additionalProperties").GetProperty("safeAnnotation").GetString()); + Assert.False( + annotation.GetProperty("additionalProperties").TryGetProperty("badAnnotation", out _)); + Assert.Equal( + 1, + annotation.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + Assert.True( + annotation.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + + AIContent restored = Assert.IsType(stored.ToAIContent()); + Assert.Equal( + "kept", + Assert.IsType(restored.AdditionalProperties?["safeString"]).GetString()); + Assert.Equal( + "annotation-kept", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["safeAnnotation"]).GetString()); + + Assert.Null(finalSerializationException); + Assert.True(logger.WarningCount >= 8); + Assert.All(logger.Exceptions, exception => Assert.Null(exception)); + Assert.All( + logger.Messages, + message => + { + Assert.DoesNotContain("disposed-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("converter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("safeString", message, StringComparison.Ordinal); + }); + } + + [Fact] + public void UnknownSubtypePropertyGetterIsNeverInvoked() + { + ThrowingFutureContent.GetterInvocationCount = 0; + ThrowingFutureContent original = new() + { + AdditionalProperties = new() + { + ["safe"] = true, + }, + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + AIContent restored = stored.ToAIContent(); + + Assert.Equal(0, ThrowingFutureContent.GetterInvocationCount); + Assert.IsType(restored); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["safe"]).GetBoolean()); + Assert.DoesNotContain("$runtimeType", json, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", json, StringComparison.Ordinal); + } + + [Fact] + public void UnknownContentDoesNotSwallowCancellation() + { + FutureContent original = new() + { + RawRepresentation = new CancelingGetterPayload(), + }; + + Assert.ThrowsAny( + () => DurableAgentStateContent.FromAIContent(original)); + } + + private sealed class FutureContent : AIContent + { + public string? FutureValue { get; init; } + } + + private sealed class CyclicPayload + { + public CyclicPayload? Self { get; set; } + } + + private sealed class ThrowingFutureContent : AIContent + { + public static int GetterInvocationCount { get; set; } + + public string Dangerous + { + get + { + GetterInvocationCount++; + throw new InvalidOperationException("getter-secret"); + } + } + } + + private sealed class ThrowingGetterPayload + { + public string Dangerous => throw new InvalidOperationException("getter-secret"); + } + + private sealed class CancelingGetterPayload + { + public string Dangerous => throw new OperationCanceledException(); + } + + [JsonConverter(typeof(ThrowingConverterPayloadConverter))] + public sealed class ThrowingConverterPayload; + + public sealed class ThrowingConverterPayloadConverter : JsonConverter + { + public override ThrowingConverterPayload? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write( + Utf8JsonWriter writer, + ThrowingConverterPayload value, + JsonSerializerOptions options) + { + throw new FormatException("converter-secret"); + } + } + + private sealed class CollectingLogger : ILogger + { + public int WarningCount { get; private set; } + + public List Messages { get; } = []; + + public List Exceptions { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + this.WarningCount++; + this.Messages.Add(formatter(state, exception)); + this.Exceptions.Add(exception); + } + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs index ea117f9..62c9493 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs @@ -110,5 +110,45 @@ public void PreviouslyPersistedArgumentsAreStillReadable() Assert.Equal(3, Assert.IsType(result.Arguments["days"]).GetInt32()); } + [Fact] + public void StringArgumentsRoundTripVerbatimWithoutParsing() + { + const string Json = + """{"$type":"functionCall","arguments":" { \"partial\": ","callId":"call-7","name":"incomplete"}"""; + + DurableAgentStateContent? deserialized = + (DurableAgentStateContent?)JsonSerializer.Deserialize(Json, s_stateContentTypeInfo); + DurableAgentStateFunctionCallContent durable = + Assert.IsType(deserialized); + string roundTrip = JsonSerializer.Serialize(durable, s_stateContentTypeInfo); + using JsonDocument roundTripDocument = JsonDocument.Parse(roundTrip); + FunctionCallContent runtime = Assert.IsType(durable.ToAIContent()); + + Assert.Equal(" { \"partial\": ", durable.Arguments.GetString()); + Assert.Equal(" { \"partial\": ", runtime.RawRepresentation); + Assert.Equal( + " { \"partial\": ", + roundTripDocument.RootElement.GetProperty("arguments").GetString()); + } + + [Fact] + public void ProductionMappingDoesNotEmitV2StringArguments() + { + FunctionCallContent runtime = new("call-8", "future") + { + RawRepresentation = "verbatim", + }; + + DurableAgentStateFunctionCallContent legacy = + Assert.IsType( + DurableAgentStateContent.FromAIContent(runtime)); + DurableAgentStateFunctionCallContent revised = + Assert.IsType( + DurableAgentStateContent.FromAIContentV2(runtime)); + + Assert.Equal(JsonValueKind.Undefined, legacy.Arguments.ValueKind); + Assert.Equal("verbatim", revised.Arguments.GetString()); + } + private sealed record Location(string City, string State); } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs new file mode 100644 index 0000000..80df8d6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMailboxTests.cs @@ -0,0 +1,1526 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateMailboxTests +{ + [Fact] + public void VersionedEnvelopeCasesMatchDotNetReaders() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "versioned-envelope-cases.json")); + using JsonDocument document = JsonDocument.Parse(json); + int caseCount = 0; + int schemaOnlyLegacyCases = 0; + + foreach (JsonElement group in document.RootElement.EnumerateArray()) + { + foreach (JsonElement test in group.GetProperty("tests").EnumerateArray()) + { + string stateJson = test.GetProperty("data").GetRawText(); + bool valid = test.GetProperty("valid").GetBoolean(); + if (valid) + { + JsonElement data = test.GetProperty("data"); + if (IsSchemaOnlyLegacyEntryCase(data)) + { + // Historical schema snapshots allowed an undiscriminated generic entry. + // The existing .NET model has always required a typed entry discriminator; + // this implementation must not invent one while round-tripping old data. + schemaOnlyLegacyCases++; + } + else + { + DurableAgentState state = Deserialize(stateJson); + _ = Serialize(state); + } + } + else + { + Assert.ThrowsAny(() => Deserialize(stateJson)); + } + + caseCount++; + } + } + + Assert.Equal(44, caseCount); + Assert.Equal(3, schemaOnlyLegacyCases); + } + + [Theory] + [InlineData("""{"role":"developer","contents":[]}""")] + [InlineData("""{"role":"assistant","contents":[{"$type":"functionCall","callId":"c","name":"f","arguments":"verbatim"}]}""")] + [InlineData("""{"role":"assistant","contents":[{"$type":"uri","uri":"https://example.test/media"}]}""")] + public void LegacySnapshotsRejectV2OnlyMessageShapes(string messageJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{{messageJson}}] + }] + } + } + """; + + Assert.Throws(() => Deserialize(json)); + } + + [Theory] + [InlineData("null")] + [InlineData("[null]")] + public void LegacySnapshotsRejectMalformedConversationHistory(string historyJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": {{historyJson}} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("""{"$type":"request","messages":null}""")] + [InlineData("""{"$type":"request","messages":[null]}""")] + [InlineData("""{"$type":"request","messages":[{"role":null}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":null}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[null]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"text","text":null}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"functionCall","callId":null,"name":"f"}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"uri","uri":null,"mediaType":"text/plain"}]}]}""")] + [InlineData("""{"$type":"request","messages":[{"role":"user","contents":[{"$type":"usage","usage":null}]}]}""")] + public void LegacySnapshotsRejectMalformedEntryShapes(string entryJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{{entryJson}}] + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void LegacyStateRoundTripsWithoutRevisedFields() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [] + } + } + """; + + DurableAgentState state = Deserialize(Json); + string roundTrip = Serialize(state); + + Assert.DoesNotContain("\"terminalResults\"", roundTrip, StringComparison.Ordinal); + Assert.DoesNotContain("\"completionReceipts\"", roundTrip, StringComparison.Ordinal); + Assert.DoesNotContain("\"historyBinding\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void 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() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")); + + DurableAgentState state = Deserialize(json); + string roundTrip = Serialize(state); + DurableAgentStateTerminalResult result = Assert.IsType( + state.Data.TerminalResults?["corr-2"]); + DurableAgentStateCompletionReceipt unavailable = Assert.IsType( + state.Data.CompletionReceipts?["corr-expired"]); + + Assert.Equal(DurableAgentState.RevisedSchemaVersion, state.SchemaVersion); + Assert.Equal( + "contoso.support-history.v1", + state.Data.HistoryBinding.GetProperty("providerKey").GetString()); + Assert.Equal("response-id-2", result.Response?.ResponseId); + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, unavailable.ResultState); + Assert.Contains("\"futureResponseField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureReceiptField\":7", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureBindingField\":\"preserve\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureRootField\":{\"preserve\":true}", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("terminalResults")] + [InlineData("completionReceipts")] + public void RevisedStateRequiresCompleteLayout(string missingProperty) + { + Dictionary data = new() + { + ["conversationHistory"] = Array.Empty(), + ["terminalResults"] = new Dictionary(), + ["completionReceipts"] = new Dictionary(), + ["historyBinding"] = new + { + version = 1, + ownerKind = "durableState", + providerKey = "durable-state.v1", + }, + }; + _ = data.Remove(missingProperty); + string json = JsonSerializer.Serialize(new + { + schemaVersion = DurableAgentState.RevisedSchemaVersion, + data, + }); + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void RevisedStateAllowsOmittedHistoryBinding() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal(JsonValueKind.Undefined, state.Data.HistoryBinding.ValueKind); + } + + [Theory] + [InlineData("request", "")] + [InlineData("response", " ")] + [InlineData("errorResponse", "id\u0001")] + public void RevisedTranscriptRejectsInvalidPresentCorrelation(string entryType, string correlationId) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "{{entryType}}", + "correlationId": {{JsonSerializer.Serialize(correlationId)}} + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void RevisedTranscriptAllowsMissingCorrelationButCompactionForbidsIt() + { + const string MissingCorrelation = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ "$type": "request" }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + const string CompactionCorrelation = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "compaction", + "correlationId": "not-allowed" + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.IsType( + Assert.Single(Deserialize(MissingCorrelation).Data.ConversationHistory)); + Assert.Throws(() => Deserialize(CompactionCorrelation)); + } + + [Fact] + public void TranscriptEntryPreservesAbsentCreatedAt() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ "$type": "request" }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement entry = document.RootElement.GetProperty("data").GetProperty("conversationHistory")[0]; + + Assert.False(entry.TryGetProperty("createdAt", out _)); + } + + [Fact] + public void EntryPreservesFieldsOwnedByAnotherVariantAsUnknown() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "usage": { + "extensionData": null + } + }, { + "$type": "response", + "responseSchema": "opaque" + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"usage\":{\"extensionData\":null}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"responseSchema\":\"opaque\"", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("conversationHistory")] + [InlineData("terminalResults.messages")] + public void RevisedStateRejectsNullRequiredCollections(string collection) + { + string json = collection == "conversationHistory" + ? """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": null, + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """ + : """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "correlation": { + "correlationId": "correlation", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": null } + } + }, + "completionReceipts": { + "correlation": { + "correlationId": "correlation", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void TerminalMessageMayOmitContentsButCannotUseNullEntries() + { + const string MetadataOnlyJson = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [{ "role": "assistant" }] } + } + }, + "completionReceipts": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + const string NullMessageJson = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [null] } + } + }, + "completionReceipts": { + "metadata": { + "correlationId": "metadata", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + AgentResponse response = Assert.IsType( + Deserialize(MetadataOnlyJson).Data.TerminalResults?["metadata"].Response).ToResponse(); + Assert.Empty(Assert.Single(response.Messages).Contents); + Assert.Throws(() => Deserialize(NullMessageJson)); + } + + [Fact] + public void UnknownMailboxDiscriminatorIsRejected() + { + string json = CreateRevisedJson( + resultOutcome: "futureOutcome", + receiptOutcome: "futureOutcome", + resultState: DurableAgentStateCompletionReceipt.AvailableResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void DuplicateCompletionCorrelationIsRejected() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "duplicate": { + "correlationId": "duplicate", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-10T05:00:01+00:00" + }, + "duplicate": { + "correlationId": "duplicate", + "outcome": "succeeded", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-10T05:00:01+00:00" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Theory] + [InlineData("available", false)] + [InlineData("unavailable", true)] + public void ResultAndReceiptAvailabilityMustBeConsistent(string resultState, bool includeResult) + { + string json = CreateRevisedJson( + resultOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + receiptOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + resultState, + includeResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void ResultAndReceiptMetadataMustMatch() + { + string json = CreateRevisedJson( + resultOutcome: DurableAgentStateCompletionReceipt.SucceededOutcome, + receiptOutcome: DurableAgentStateCompletionReceipt.FailedOutcome, + resultState: DurableAgentStateCompletionReceipt.AvailableResult); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void FailedTerminalResultWithMatchingReceiptIsValid() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "failed": { + "correlationId": "failed", + "outcome": "failed", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { + "messages": [{ + "role": "assistant", + "contents": [{ + "$type": "error", + "message": "failed", + "errorCode": "Example" + }] + }] + }, + "error": { + "code": "Example", + "message": "The operation failed." + } + } + }, + "completionReceipts": { + "failed": { + "correlationId": "failed", + "outcome": "failed", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "available" + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal( + "Example", + state.Data.TerminalResults?["failed"].Error?.Code); + } + + [Fact] + public void ExplicitResultRemovalMayPrecedeScheduledExpiry() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "removed": { + "correlationId": "removed", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "resultState": "unavailable", + "resultExpiresAt": "2026-09-12T10:00:00Z", + "resultUnavailableAt": "2026-09-11T11:00:00Z" + } + } + } + } + """; + + DurableAgentState state = Deserialize(Json); + + Assert.Equal( + DateTimeOffset.Parse("2026-09-11T11:00:00Z"), + state.Data.CompletionReceipts?["removed"].ResultUnavailableAt); + } + + [Fact] + public void TerminalErrorLengthCountsUnicodeScalars() + { + DurableAgentState state = CreateEmptyRevisedState(); + const string CorrelationId = "failed"; + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:00+00:00"); + state.Data.TerminalResults![CorrelationId] = new() + { + CorrelationId = CorrelationId, + Outcome = DurableAgentStateCompletionReceipt.FailedOutcome, + CompletedAt = completedAt, + Response = new(), + Error = new() + { + Code = "Example", + Message = string.Concat(Enumerable.Repeat("\U0001F600", 10_000)), + }, + }; + state.Data.CompletionReceipts![CorrelationId] = new() + { + CorrelationId = CorrelationId, + Outcome = DurableAgentStateCompletionReceipt.FailedOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }; + + string json = Serialize(state); + + Assert.Equal( + 10_000, + Deserialize(json).Data.TerminalResults![CorrelationId].Error!.Message.EnumerateRunes().Count()); + } + + [Fact] + public void TerminalErrorMessageAllowsContractValidControlCharacters() + { + DurableAgentStateTerminalError error = new() + { + Code = "Example", + Message = "line\u0001break", + }; + + error.Validate(); + + Assert.Contains('\u0001', error.Message); + } + + [Fact] + public void TerminalResponseMetadataRequiresValidKeys() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace("\"region\": \"test\"", "\"\": \"test\"", StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void HistoryBindingIsPreservedAsOpaqueRuntimeProfile() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": { + "runtime": "csharp", + "version": -1, + "ownerKind": null, + "nested": { + "$runtimeType": "inert" + } + } + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"version\":-1", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"ownerKind\":null", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"$runtimeType\":\"inert\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponsePreservesConsumerFieldsWithoutRuntimeObjects() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"); + ChatMessage message = new( + ChatRole.Assistant, + [ + new TextContent("done"), + new UriContent("https://example.test/result.json", "application/json"), + ]) + { + MessageId = "message-id", + AuthorName = "agent", + }; + AgentResponse response = new([message]) + { + CreatedAt = completedAt, + ResponseId = "response-id", + AgentId = "agent-id", + FinishReason = new ChatFinishReason("stop"), + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), + Usage = new UsageDetails + { + InputTokenCount = 4, + OutputTokenCount = 2, + TotalTokenCount = 6, + }, + AdditionalProperties = new() + { + ["region"] = "test", + ["attempt"] = 2, + }, + RawRepresentation = new object(), + }; + + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + completedAt); + AgentResponse restored = Assert.IsType(stored.Response).ToResponse(); + + Assert.Equal("response-id", restored.ResponseId); + Assert.Equal("agent-id", restored.AgentId); + Assert.Equal("stop", restored.FinishReason?.Value); + Assert.Equal(completedAt, restored.CreatedAt); + Assert.Equal([1, 2, 3], restored.ContinuationToken?.ToBytes().ToArray()); + Assert.Equal(6, restored.Usage?.TotalTokenCount); + Assert.Equal("test", Assert.IsType(restored.AdditionalProperties?["region"]).GetString()); + Assert.Equal(2, Assert.IsType(restored.AdditionalProperties?["attempt"]).GetInt32()); + Assert.Null(restored.RawRepresentation); + ChatMessage restoredMessage = Assert.Single(restored.Messages); + Assert.Equal("message-id", restoredMessage.MessageId); + Assert.Collection( + restoredMessage.Contents, + content => Assert.Equal("done", Assert.IsType(content).Text), + content => + { + UriContent uri = Assert.IsType(content); + Assert.Equal("https://example.test/result.json", uri.Uri.ToString()); + Assert.Equal("application/json", uri.MediaType); + }); + } + + [Theory] + [InlineData("null", JsonValueKind.Null)] + [InlineData("false", JsonValueKind.False)] + [InlineData("0", JsonValueKind.Number)] + [InlineData("\"\"", JsonValueKind.String)] + [InlineData("[]", JsonValueKind.Array)] + [InlineData("{}", JsonValueKind.Object)] + public void TerminalResponsePreservesPresentStructuredValue(string valueJson, JsonValueKind expectedKind) + { + using JsonDocument valueDocument = JsonDocument.Parse(valueJson); + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse(), + DateTimeOffset.Parse("2026-09-11T10:00:00+00:00"), + structuredValue: valueDocument.RootElement); + + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult); + DurableAgentStateTerminalResult restored = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult)); + + Assert.Contains("\"value\":", json, StringComparison.Ordinal); + Assert.Equal(expectedKind, Assert.IsType(restored.Response).Value.ValueKind); + } + + [Fact] + public void TerminalResponsePreservesAbsentStructuredValue() + { + DurableAgentStateTerminalResult stored = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse(), + DateTimeOffset.Parse("2026-09-11T10:00:00+00:00")); + + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalResult); + + Assert.DoesNotContain("\"value\"", json, StringComparison.Ordinal); + Assert.Equal( + JsonValueKind.Undefined, + Assert.IsType(stored.Response).Value.ValueKind); + } + + [Fact] + public void TerminalResponseRejectsArbitraryRuntimeMetadata() + { + AgentResponse response = new() + { + AdditionalProperties = new() + { + ["unsupported"] = new object(), + }, + }; + + InvalidOperationException exception = Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + + Assert.Contains("unsupported runtime type", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponseRejectsArbitraryMessageMetadata() + { + ChatMessage message = new(ChatRole.Assistant, "done") + { + AdditionalProperties = new() + { + ["unsupported"] = new object(), + }, + }; + + InvalidOperationException exception = Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse([message]), + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + + Assert.Contains("unsupported runtime type", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void TerminalResponseRejectsRuntimeBackedJsonNodeMetadata() + { + AgentResponse response = new() + { + AdditionalProperties = new() + { + ["unsupported"] = JsonValue.Create(new Dictionary + { + ["runtimeValue"] = 42, + }), + }, + }; + + Assert.Throws( + () => DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"))); + } + + [Fact] + public void NonCanonicalContinuationTokenIsRejected() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace("\"AQID\"", "\"AQ ID\"", StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalResultClonesJsonElementContent() + { + DurableAgentStateTerminalResult result; + using (JsonDocument document = JsonDocument.Parse("""{"value":1}""")) + { + ChatMessage message = new( + ChatRole.Assistant, + [new FunctionResultContent("call-1", document.RootElement)]); + result = DurableAgentStateTerminalResult.FromResponse( + "correlation", + new AgentResponse([message]), + DateTimeOffset.Parse("2026-09-10T05:00:03+00:00")); + } + + AgentResponse restored = Assert.IsType(result.Response).ToResponse(); + FunctionResultContent content = + Assert.IsType(Assert.Single(Assert.Single(restored.Messages).Contents)); + Assert.Equal(1, Assert.IsType(content.Result).GetProperty("value").GetInt32()); + } + + [Fact] + public void TerminalResultIsDetachedFromTranscriptAndSourceResponse() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-10T05:00:03+00:00"); + ChatMessage sourceMessage = new(ChatRole.Assistant, "original"); + AgentResponse response = new([sourceMessage]); + DurableAgentStateTerminalResult result = DurableAgentStateTerminalResult.FromResponse( + "correlation", + response, + completedAt); + DurableAgentStateResponse transcript = DurableAgentStateResponse.FromResponse("correlation", response); + + sourceMessage.Contents.Clear(); + transcript.Messages[0].MessageId = "transcript-mutated"; + + DurableAgentStateMessage resultMessage = + Assert.Single(Assert.IsType(result.Response).Messages); + Assert.Single(resultMessage.Contents); + Assert.Equal("durable_result_correlation_0", resultMessage.MessageId); + } + + [Fact] + public void VersionOneStateCannotWriteRevisedFields() + { + DurableAgentState state = new() + { + Data = new() + { + TerminalResults = new Dictionary(), + }, + }; + + Assert.Throws(() => Serialize(state)); + } + + [Theory] + [InlineData("terminalResults")] + [InlineData("completionReceipts")] + [InlineData("historyBinding")] + public void LegacyStateRejectsPresentNullRevisedFields(string propertyName) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "{{propertyName}}": null + } + } + """; + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void RevisedStatePreservesNullHistoryProfileWhenPresent() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": {}, + "historyBinding": null + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"historyBinding\":null", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("""{"schemaVersion":"1.2.0","extensionData":null,"data":{"conversationHistory":[]}}""")] + [InlineData("""{"schemaVersion":"1.2.0","data":{"conversationHistory":[],"extensionData":null}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultExpiresAt":null,"response":{"messages":[]}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"extensionData":null}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[{"$type":"request","responseSchema":null}],"terminalResults":{},"completionReceipts":{}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[{"$type":"request","messages":[{"role":"user","createdAt":null}]}],"terminalResults":{},"completionReceipts":{}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"usage":null}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + [InlineData("""{"schemaVersion":"2.0.0","data":{"conversationHistory":[],"terminalResults":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","response":{"messages":[],"usage":{"inputTokenCount":null}}}},"completionReceipts":{"c":{"correlationId":"c","outcome":"succeeded","completedAt":"2026-09-11T10:00:00Z","resultState":"available"}}}}""")] + public void ExplicitNullKnownFieldsAreRejected(string json) + { + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void TerminalErrorDetailsPreservesAbsentAndExplicitNull() + { + const string ExplicitNull = """ + { + "code": "Example", + "message": "failed", + "details": null + } + """; + + DurableAgentStateTerminalError present = Assert.IsType( + JsonSerializer.Deserialize( + ExplicitNull, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError)); + DurableAgentStateTerminalError absent = new() + { + Code = "Example", + Message = "failed", + }; + string presentJson = JsonSerializer.Serialize( + present, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError); + string absentJson = JsonSerializer.Serialize( + absent, + DurableAgentStateJsonContext.Default.DurableAgentStateTerminalError); + + Assert.Equal(JsonValueKind.Null, present.Details.ValueKind); + Assert.Contains("\"details\":null", presentJson, StringComparison.Ordinal); + Assert.DoesNotContain("\"details\"", absentJson, StringComparison.Ordinal); + } + + [Fact] + public void IngestionPositionsMustBeNonNegative() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "ingestedPositions": { + "producer": -1 + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Fact] + public void TruncationRequiresCompleteValidEvidence() + { + const string MissingFields = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": {} + } + } + """; + DurableAgentState invalidState = new() + { + Data = new() + { + Truncation = new() + { + EvictedMessageCount = 1, + FirstEvictedAt = DateTimeOffset.Parse("2026-09-11T11:00:00+00:00"), + LastEvictedAt = DateTimeOffset.Parse("2026-09-11T10:00:00+00:00"), + }, + }, + }; + + Assert.Throws(() => Deserialize(MissingFields)); + Assert.Throws(() => Serialize(invalidState)); + } + + [Fact] + public void TruncationUnknownEvidenceRoundTrips() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": { + "evictedMessageCount": 2, + "firstEvictedAt": "2026-09-11T10:00:00Z", + "lastEvictedAt": "2026-09-11T11:00:00Z", + "futureEvidence": 42 + } + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + + Assert.Contains("\"futureEvidence\":42", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void MailboxCrossMapComparisonIsAlwaysOrdinal() + { + DateTimeOffset completedAt = DateTimeOffset.Parse("2026-09-11T10:00:00Z"); + DurableAgentState state = new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new() + { + TerminalResults = new Dictionary( + StringComparer.OrdinalIgnoreCase) + { + ["Case-ID"] = new() + { + CorrelationId = "Case-ID", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + Response = new(), + }, + }, + CompletionReceipts = new Dictionary( + StringComparer.OrdinalIgnoreCase) + { + ["case-id"] = new() + { + CorrelationId = "case-id", + Outcome = DurableAgentStateCompletionReceipt.SucceededOutcome, + CompletedAt = completedAt, + ResultState = DurableAgentStateCompletionReceipt.AvailableResult, + }, + }, + }, + }; + + Assert.Throws(() => Serialize(state)); + } + + [Theory] + [InlineData( + "\"completedAt\": \"2026-09-10T05:00:03+00:00\"", + "\"completedAt\": \"2026-09-10T05:00:03\"")] + [InlineData( + "\"resultExpiresAt\": \"2026-09-11T05:00:03+00:00\"", + "\"resultExpiresAt\": \"2026-09-11T05:00:03\"")] + [InlineData( + "\"resultUnavailableAt\": \"2026-09-10T05:00:04+00:00\"", + "\"resultUnavailableAt\": \"2026-09-10T05:00:04\"")] + public void RevisedMailboxRequiresOffsetBearingRfc3339Timestamps( + string original, + string invalid) + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0.json")) + .Replace(original, invalid, StringComparison.Ordinal); + + Assert.Throws(() => Deserialize(json)); + } + + [Fact] + public void UnavailableReceiptIsTimestampValidatedWithoutTerminalResults() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": {}, + "completionReceipts": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-11T10:00:00Z", + "resultState": "unavailable", + "resultUnavailableAt": "2026-09-11T11:00:00" + } + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Fact] + public void LegacyTruncationRequiresOffsetAndSeconds() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "truncation": { + "evictedMessageCount": 1, + "firstEvictedAt": "2026-09-11T10:00Z", + "lastEvictedAt": "2026-09-11T11:00:00Z" + } + } + } + """; + + Assert.Throws(() => Deserialize(Json)); + } + + [Theory] + [InlineData("""{"$type":"text","text":null}""")] + [InlineData("""{"$type":"functionCall","callId":"c","name":null}""")] + [InlineData("""{"$type":"uri","uri":"https://example.test","mediaType":null}""")] + [InlineData("""{"$type":"usage","usage":{"inputTokenCount":null}}""")] + [InlineData("""{"$type":"usage","usage":{"extensionData":null}}""")] + public void RevisedStateRejectsMalformedKnownContent(string contentJson) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [{{contentJson}}] + }] + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("inputTokenCount")] + [InlineData("outputTokenCount")] + [InlineData("totalTokenCount")] + [InlineData("extensionData")] + public void LegacyUsageContentRejectsExplicitNullKnownFields(string propertyName) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [{ + "$type": "usage", + "usage": { + "{{propertyName}}": null + } + }] + }] + }] + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Theory] + [InlineData("\"authorName\":null")] + [InlineData("\"messageId\":null")] + [InlineData("\"createdAt\":null")] + [InlineData("\"extensionData\":null")] + public void TerminalMessagesRejectExplicitNullKnownFields(string property) + { + string json = $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-12T00:00:00Z", + "response": { + "messages": [{ + "role": "assistant", + {{property}} + }] + } + } + }, + "completionReceipts": { + "c": { + "correlationId": "c", + "outcome": "succeeded", + "completedAt": "2026-09-12T00:00:00Z", + "resultState": "available" + } + } + } + } + """; + + Assert.ThrowsAny(() => Deserialize(json)); + } + + [Fact] + public void IdentifierLengthCountsUnicodeScalars() + { + string providerKey = string.Concat(Enumerable.Repeat("\U0001F600", 200)); + DurableAgentState state = CreateEmptyRevisedState( + JsonSerializer.SerializeToElement(new + { + ownerKind = "historyProvider", + providerKey, + })); + + string json = Serialize(state); + DurableAgentState restored = Deserialize(json); + + Assert.Equal(providerKey, restored.Data.HistoryBinding.GetProperty("providerKey").GetString()); + } + + [Fact] + public void LosslessFixturePreservesDeveloperRoleArgumentsUriOpaqueContentAndValue() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0-lossless.json")); + + DurableAgentState state = Deserialize(json); + string roundTrip = Serialize(state); + DurableAgentStateRequest request = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + Assert.Equal("developer", Assert.Single(request.Messages).Role); + + DurableAgentStateTerminalResponse response = Assert.IsType( + state.Data.TerminalResults?["corr-lossless"].Response); + DurableAgentStateMessage message = Assert.Single(response.Messages); + DurableAgentStateFunctionCallContent functionCall = + Assert.IsType(message.Contents[0]); + DurableAgentStateUriContent uri = Assert.IsType(message.Contents[1]); + DurableAgentStateUnknownContent unknown = + Assert.IsType(message.Contents[2]); + + Assert.Equal(" { \"partial\": ", functionCall.Arguments.GetString()); + Assert.Null(uri.MediaType); + Assert.Equal("opaque-data-only", unknown.Content.GetProperty("$runtimeType").GetString()); + Assert.Equal(JsonValueKind.False, response.Value.ValueKind); + FunctionCallContent runtimeFunctionCall = + Assert.IsType(functionCall.ToAIContent()); + Assert.Equal(" { \"partial\": ", runtimeFunctionCall.RawRepresentation); + Assert.Throws(() => uri.ToAIContent()); + using JsonDocument roundTripDocument = JsonDocument.Parse(roundTrip); + Assert.Equal( + " { \"partial\": ", + roundTripDocument.RootElement.GetProperty("data") + .GetProperty("terminalResults") + .GetProperty("corr-lossless") + .GetProperty("response") + .GetProperty("messages")[0] + .GetProperty("contents")[0] + .GetProperty("arguments") + .GetString()); + Assert.DoesNotContain("\"mediaType\"", JsonSerializer.Serialize( + uri, + DurableAgentStateJsonContext.Default.DurableAgentStateUriContent), StringComparison.Ordinal); + } + + [Fact] + public void PrunedFixturePreservesExpiredOutcomeOpaqueSessionAndHighestSeenPosition() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-2.0-pruned.json")); + + DurableAgentState state = Deserialize(json); + DurableAgentStateCompletionReceipt receipt = + Assert.IsType(state.Data.CompletionReceipts?["corr-pruned"]); + + Assert.Equal(DurableAgentStateCompletionReceipt.SucceededOutcome, receipt.Outcome); + Assert.Equal(DurableAgentStateCompletionReceipt.UnavailableResult, receipt.ResultState); + Assert.False(state.Data.TerminalResults?.ContainsKey("corr-pruned")); + Assert.Equal(3, state.Data.IngestedPositions?["example-producer"]); + Assert.Equal( + "opaque-user-data", + state.Data.Session?.GetProperty("exampleContinuation").GetProperty("$runtimeType").GetString()); + Assert.Equal(4, state.Data.Truncation?.EvictedMessageCount); + } + + [Theory] + [InlineData("null", JsonValueKind.Null)] + [InlineData("\"verbatim\"", JsonValueKind.String)] + [InlineData("[0,false,null]", JsonValueKind.Array)] + public void ExplicitOpaqueJsonContentRoundTripsLosslessly(string contentJson, JsonValueKind expectedKind) + { + string json = $$""" + { + "$type": "unknown", + "content": {{contentJson}} + } + """; + + DurableAgentStateUnknownContent content = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateContent)); + string roundTrip = JsonSerializer.Serialize( + content, + DurableAgentStateJsonContext.Default.DurableAgentStateUnknownContent); + + using JsonDocument document = JsonDocument.Parse(roundTrip); + Assert.Equal(expectedKind, content.Content.ValueKind); + Assert.True(JsonElement.DeepEquals( + JsonDocument.Parse(contentJson).RootElement, + document.RootElement.GetProperty("content"))); + } + + [Fact] + public void KnownContentPreservesExplicitNullVersusAbsent() + { + const string Json = """ + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "messages": [{ + "role": "user", + "contents": [ + { "$type": "error", "details": null }, + { "$type": "functionResult", "callId": "null", "result": null }, + { "$type": "functionResult", "callId": "absent" } + ] + }] + }], + "terminalResults": {}, + "completionReceipts": {} + } + } + """; + + string roundTrip = Serialize(Deserialize(Json)); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement contents = document.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[0] + .GetProperty("messages")[0] + .GetProperty("contents"); + + Assert.Equal(JsonValueKind.Null, contents[0].GetProperty("details").ValueKind); + Assert.Equal(JsonValueKind.Null, contents[1].GetProperty("result").ValueKind); + Assert.False(contents[2].TryGetProperty("result", out _)); + } + + private static DurableAgentState CreateEmptyRevisedState(JsonElement binding = default) + { + return new() + { + SchemaVersion = DurableAgentState.RevisedSchemaVersion, + Data = new() + { + ConversationHistory = [], + TerminalResults = new Dictionary(), + CompletionReceipts = new Dictionary(), + HistoryBinding = binding, + }, + }; + } + + private static string CreateRevisedJson( + string resultOutcome, + string receiptOutcome, + string resultState, + bool includeResult = true) + { + string result = includeResult + ? $$""" + "correlation": { + "correlationId": "correlation", + "outcome": "{{resultOutcome}}", + "completedAt": "2026-09-10T05:00:00+00:00", + "response": { "messages": [] } + } + """ + : string.Empty; + string unavailableAt = resultState == DurableAgentStateCompletionReceipt.UnavailableResult + ? """, "resultUnavailableAt": "2026-09-10T05:00:01+00:00" """ + : string.Empty; + + return $$""" + { + "schemaVersion": "2.0.0", + "data": { + "conversationHistory": [], + "terminalResults": { {{result}} }, + "completionReceipts": { + "correlation": { + "correlationId": "correlation", + "outcome": "{{receiptOutcome}}", + "completedAt": "2026-09-10T05:00:00+00:00", + "resultState": "{{resultState}}"{{unavailableAt}} + } + }, + "historyBinding": { + "version": 1, + "ownerKind": "durableState", + "providerKey": "durable-state.v1" + } + } + } + """; + } + + private static DurableAgentState Deserialize(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("schemaVersion").GetString() == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateJsonConverter.DeserializeRevisedContract(json) + : Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + private static string Serialize(DurableAgentState state) => + state.SchemaVersion == DurableAgentState.RevisedSchemaVersion + ? DurableAgentStateJsonConverter.SerializeRevisedContract(state) + : JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + + private static bool IsSchemaOnlyLegacyEntryCase(JsonElement state) + { + if (state.GetProperty("schemaVersion").GetString() == DurableAgentState.RevisedSchemaVersion || + !state.GetProperty("data").TryGetProperty("conversationHistory", out JsonElement history)) + { + return false; + } + + return history.EnumerateArray().Any(entry => + entry.ValueKind == JsonValueKind.Object && + !entry.TryGetProperty("$type", out _)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs index 343644d..85a72f8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs @@ -8,6 +8,19 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateMessageTests { + [Fact] + public void ProductionMappingRejectsV2OnlyDeveloperRole() + { + ChatMessage message = new(new ChatRole("developer"), "instruction"); + + Assert.Throws( + () => DurableAgentStateMessage.FromChatMessage(message)); + + DurableAgentStateMessage revised = + DurableAgentStateMessage.FromTerminalChatMessage(message); + Assert.Equal("developer", revised.Role); + } + [Fact] public void MessageSerializationDeserialization() { @@ -44,4 +57,216 @@ public void MessageSerializationDeserialization() Assert.Equal(textContent.Text, convertedTextContent.Text); } + + [Fact] + public void MessageIdAndAdditionalPropertiesRoundTrip() + { + ChatMessage message = new(ChatRole.User, "hello") + { + MessageId = "message-1", + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["excluded"] = true, + ["summary"] = "summary-1", + }, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + ChatMessage restored = stored.ToChatMessage(); + + Assert.Equal("message-1", restored.MessageId); + Assert.NotNull(restored.AdditionalProperties); + Assert.Equal(JsonValueKind.True, Assert.IsType(restored.AdditionalProperties["excluded"]).ValueKind); + Assert.Equal("summary-1", Assert.IsType(restored.AdditionalProperties["summary"]).GetString()); + } + + [Fact] + public void StandaloneConversionDoesNotInventRandomIdentity() + { + DurableAgentStateMessage stored = + DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.User, "hello")); + + Assert.Null(stored.MessageId); + Assert.Null(stored.ToChatMessage().MessageId); + } + + [Fact] + public void EntryFactoriesSynthesizeDeterministicMessageIds() + { + RunRequest request = new("hello") { CorrelationId = "correlation" }; + + DurableAgentStateRequest first = DurableAgentStateRequest.FromRunRequest(request); + DurableAgentStateRequest second = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("durable_request_correlation_0", first.Messages[0].MessageId); + Assert.Equal(first.Messages[0].MessageId, second.Messages[0].MessageId); + } + + [Fact] + public void EntryFactoriesPreserveProducerMessageIds() + { + RunRequest request = new( + [new ChatMessage(ChatRole.User, "hello") { MessageId = "producer-id" }]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("producer-id", stored.Messages[0].MessageId); + } + + [Fact] + public void RequestFactoryUsesStoredPositionsWithoutFiltering() + { + RunRequest request = new( + [ + new ChatMessage(ChatRole.User, [new AIContent()]), + new ChatMessage(ChatRole.User, "hello"), + ]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal( + ["durable_request_correlation_0", "durable_request_correlation_1"], + stored.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyCorrelationlessCompactionUsesStoredPositions() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + DurableAgentStateCompaction compaction = new() + { + CreatedAt = createdAt, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextContent { Text = "summary" }], + }, + ], + }; + + DurableAgentStateMessageIdentity.EnsureMessageIds([compaction]); + + Assert.Equal( + [ + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + ], + compaction.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyEmptyCorrelationIdUsesTimestampScope() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + + string messageId = DurableAgentStateMessageIdentity.Create( + "compaction", + string.Empty, + createdAt, + storedIndex: 1); + + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + messageId); + } + + [Fact] + public void AdditionalPropertiesAreCopiedOnBothConversions() + { + AdditionalPropertiesDictionary producerProperties = new() + { + ["marker"] = "original", + }; + ChatMessage message = new(ChatRole.User, "hello") + { + AdditionalProperties = producerProperties, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + producerProperties["marker"] = "producer-mutated"; + ChatMessage restored = stored.ToChatMessage(); + restored.AdditionalProperties!["marker"] = "consumer-mutated"; + + Assert.Equal("original", stored.AdditionalProperties?["marker"].GetString()); + } + + [Theory] + [InlineData("2026-07-27T12:34:56+00:00", "2026-07-27T12:34:56+00:00")] + [InlineData("2026-07-27T12:34:56.1234567+00:00", "2026-07-27T12:34:56.123456+00:00")] + [InlineData("2026-07-27T12:34:56.1000000+05:30", "2026-07-27T12:34:56.100000+05:30")] + public void CorrelationlessTimestampScopeMatchesPythonIsoFormat(string input, string expected) + { + DateTimeOffset timestamp = DateTimeOffset.Parse(input); + + string actual = DurableAgentStateMessageIdentity.FormatPythonIsoTimestamp(timestamp); + + Assert.Equal(expected, actual); + } + + [Fact] + public void VersionOnePointTwoFixtureRoundTrips() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "correlationId": "request-1", + "createdAt": "2026-01-01T00:00:00Z", + "messages": [{ + "role": "user", + "messageId": "message-1", + "extensionData": { "excluded": true }, + "contents": [{ "$type": "text", "text": "hello" }] + }] + }], + "session": { + "conversationId": "service-1", + "stateBag": {} + }, + "ingestedPositions": { + "input": 0, + "writer": 1 + }, + "truncation": { + "evictedMessageCount": 2, + "firstEvictedAt": "2026-01-01T00:00:00Z", + "lastEvictedAt": "2026-01-02T00:00:00Z" + } + } + } + """; + + DurableAgentState? state = JsonSerializer.Deserialize( + Json, + DurableAgentStateJsonContext.Default.DurableAgentState); + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.NotNull(state); + Assert.Equal("1.2.0", state.SchemaVersion); + Assert.Equal("message-1", state.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal(0, state.Data.IngestedPositions?["input"]); + Assert.Equal(1, state.Data.IngestedPositions?["writer"]); + Assert.Contains("\"conversationId\":\"service-1\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"ingestedPositions\":{\"input\":0,\"writer\":1}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"evictedMessageCount\":2", roundTrip, StringComparison.Ordinal); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs index a974f9d..66caf9f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateResponseTests { [Fact] - public void FromResponseDropsMessagesContainingOnlyOpaqueContent() + public void FromResponsePreservesMessagesContainingOnlyOpaqueContent() { // Arrange: one message with real text, one with only opaque AIContent ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!") @@ -32,15 +32,14 @@ public void FromResponseDropsMessagesContainingOnlyOpaqueContent() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response); - // Assert: only the useful message survives - DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); - Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); + Assert.Equal(2, durableResponse.Messages.Count); + Assert.Equal(ChatRole.Assistant.Value, durableResponse.Messages[1].Role); - // Round-trip to verify the content is correct AgentResponse convertedResponse = durableResponse.ToResponse(); - ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages); - TextContent textContent = Assert.IsType(Assert.Single(convertedMessage.Contents)); + Assert.Equal(2, convertedResponse.Messages.Count); + TextContent textContent = Assert.IsType(Assert.Single(convertedResponse.Messages[0].Contents)); Assert.Equal("Hello, world!", textContent.Text); + Assert.IsType(Assert.Single(convertedResponse.Messages[1].Contents)); } [Fact] @@ -68,7 +67,7 @@ public void FromResponseKeepsMessagesWithMixedContent() } [Fact] - public void FromResponseDropsAllMessagesWhenAllAreOpaque() + public void FromResponsePreservesAllMessagesWhenAllAreOpaque() { // Arrange: all messages contain only opaque AIContent ChatMessage opaque1 = new(ChatRole.Assistant, [ @@ -90,8 +89,7 @@ public void FromResponseDropsAllMessagesWhenAllAreOpaque() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response); - // Assert: no messages stored - Assert.Empty(durableResponse.Messages); + Assert.Equal(2, durableResponse.Messages.Count); } [Fact] @@ -139,4 +137,133 @@ public void FromResponseKeepsBaseAIContentWithAdditionalProperties() // Assert: message is kept because the AIContent has additional properties Assert.Single(durableResponse.Messages); } + + [Fact] + public void FromResponseUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AdditionalProperties = new() { ["kind"] = "metadata" }, + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + AgentResponse response = new([metadataOnly, text]); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", response); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("durable_response_correlation_0", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromMessagesUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + MessageId = "producer-metadata-id", + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromMessages("correlation", [metadataOnly, text]); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("producer-metadata-id", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromResponsePreservesProducerIdAfterFiltering() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []); + ChatMessage text = new(ChatRole.Assistant, "kept") + { + MessageId = "producer-id", + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly, text])); + + Assert.Equal("producer-id", stored.Messages[1].MessageId); + } + + [Fact] + public void MetadataOnlyResponsePersistsAndRoundTrips() + { + DateTimeOffset createdAt = DateTimeOffset.Parse("2026-09-06T12:34:56+00:00"); + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AuthorName = "agent", + CreatedAt = createdAt, + MessageId = "producer-message-id", + AdditionalProperties = new() + { + ["trace"] = "value", + }, + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly])); + string json = System.Text.Json.JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse); + DurableAgentStateResponse restored = Assert.IsType( + System.Text.Json.JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse)); + ChatMessage roundTripped = Assert.Single(restored.ToResponse().Messages); + + Assert.Empty(roundTripped.Contents); + Assert.Equal(ChatRole.Assistant, roundTripped.Role); + Assert.Equal("agent", roundTripped.AuthorName); + Assert.Equal(createdAt, roundTripped.CreatedAt); + Assert.Equal("producer-message-id", roundTripped.MessageId); + Assert.Equal( + "value", + Assert.IsType( + roundTripped.AdditionalProperties?["trace"]).GetString()); + } + + [Fact] + public void ToResponseRetainsMetadataOnlyMessageForPolling() + { + DurableAgentStateResponse stored = new() + { + CorrelationId = "correlation", + CreatedAt = DateTimeOffset.Parse("2026-09-06T12:00:00+00:00"), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + MessageId = "pollable-metadata", + AdditionalProperties = new Dictionary + { + ["status"] = System.Text.Json.JsonSerializer.SerializeToElement("complete"), + }, + Contents = [], + }, + ], + }; + + ChatMessage message = Assert.Single(stored.ToResponse().Messages); + + Assert.Equal("pollable-metadata", message.MessageId); + Assert.Empty(message.Contents); + Assert.Equal( + "complete", + Assert.IsType( + message.AdditionalProperties?["status"]).GetString()); + } + + [Fact] + public void EmptyResponseGetsCreatedAtWithoutThrowing() + { + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse()); + + Assert.Empty(stored.Messages); + Assert.NotEqual(default, stored.CreatedAt); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs index f8ce5c6..8a0f0a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -2,11 +2,22 @@ using System.Text.Json; using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateTests { + [Fact] + public void NewStateDefaultsToCurrentSchemaVersion() + { + DurableAgentState state = new(); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, state.SchemaVersion); + Assert.Equal("1.2.0", state.SchemaVersion); + Assert.Equal("2.0.0", DurableAgentState.RevisedSchemaVersion); + } + [Fact] public void InvalidVersion() { @@ -22,13 +33,106 @@ public void InvalidVersion() () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); } + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + [InlineData("1.2.0")] + public void DeclaredSchemaVersionsAreAccepted(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + Assert.Equal(version, state.SchemaVersion); + } + + [Theory] + [InlineData("1.2")] + [InlineData("1.2.0.0")] + [InlineData("v1.2.0")] + [InlineData("")] + [InlineData(" ")] + [InlineData("-1.2.0")] + [InlineData("1.-2.0")] + [InlineData("1.2.-3")] + [InlineData("01.2.0")] + [InlineData("1.02.0")] + [InlineData("1.2.00")] + [InlineData("1.2.0-alpha")] + [InlineData("1.2.0+build")] + [InlineData("1.2.0-alpha+build")] + [InlineData("1.0.7")] + [InlineData("1.1.9")] + [InlineData("1.2.7")] + [InlineData("1.3.0")] + [InlineData("2.0.1")] + [InlineData("2.1.0")] + [InlineData("1.2147483648.0")] + [InlineData("1.2.2147483648")] + public void InvalidOrUndeclaredSchemaVersionIsRejected(string version) + { + string json = $$""" + { + "schemaVersion": {{JsonSerializer.Serialize(version)}}, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void NonStringSchemaVersionIsRejected() + { + const string JsonText = """ + { + "schemaVersion": 10200, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + JsonText, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void InvalidSchemaVersionCannotBeSerialized() + { + DurableAgentState state = new() + { + SchemaVersion = "1.2", + }; + + Assert.Throws( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + [Fact] - public void BreakingVersion() + public void UnsupportedMajorVersion() { // Arrange const string JsonText = """ { - "schemaVersion": "2.0.0" + "schemaVersion": "3.0.0" } """; @@ -53,7 +157,7 @@ public void MissingData() } [Fact] - public void ExtraData() + public void UnknownDataPropertiesRoundTrip() { // Arrange const string JsonText = """ @@ -70,10 +174,10 @@ public void ExtraData() DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState); // Assert - Assert.NotNull(state?.Data?.ExtensionData); + Assert.NotNull(state?.Data?.UnknownProperties); - Assert.True(state.Data.ExtensionData!.ContainsKey("extraField")); - Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString()); + Assert.True(state.Data.UnknownProperties!.ContainsKey("extraField")); + Assert.Equal("someValue", state.Data.UnknownProperties["extraField"].ToString()); // Act string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); @@ -86,6 +190,137 @@ public void ExtraData() Assert.Equal("someValue", extraFieldElement.ToString()); } + [Fact] + public void OpaqueSessionIsClonedFromCallerOwnedJson() + { + DurableAgentState state = new(); + using (JsonDocument document = JsonDocument.Parse( + """{"conversationId":"service-1","$runtimeType":"Untrusted.Type, Untrusted.Assembly"}""")) + { + state.Data.Session = document.RootElement; + } + + string json = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + DurableAgentState restored = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + JsonElement session = Assert.IsType(restored.Data.Session); + Assert.Equal(JsonValueKind.Object, session.ValueKind); + Assert.Equal("service-1", session.GetProperty("conversationId").GetString()); + Assert.Equal( + "Untrusted.Type, Untrusted.Assembly", + session.GetProperty("$runtimeType").GetString()); + Assert.IsType(restored.Data.Session); + } + + [Theory] + [InlineData("null")] + [InlineData("\"session\"")] + [InlineData("[]")] + [InlineData("42")] + public void OpaqueSessionMustBeAJsonObject(string sessionJson) + { + string json = $$""" + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [], + "session": {{sessionJson}} + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void DeclaredExtensionDataAndUnknownPropertiesRoundTripIndependently() + { + const string JsonText = """ + { + "schemaVersion": "1.2.0", + "extensionData": { "rootMetadata": "root" }, + "futureRoot": 1, + "data": { + "extensionData": { "dataMetadata": "data" }, + "futureData": 2, + "conversationHistory": [{ + "$type": "response", + "correlationId": "correlation", + "createdAt": "2026-09-07T12:00:00+00:00", + "extensionData": { "entryMetadata": "entry" }, + "futureEntry": 3, + "usage": { + "extensionData": { "providerCount": 4 }, + "futureUsage": 5 + }, + "messages": [{ + "role": "assistant", + "extensionData": { "messageMetadata": "message" }, + "futureMessage": 6, + "contents": [{ + "$type": "text", + "text": "answer", + "futureContent": 7 + }] + }] + }] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + DurableAgentStateResponse response = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + DurableAgentStateMessage message = Assert.Single(response.Messages); + DurableAgentStateTextContent content = + Assert.IsType(Assert.Single(message.Contents)); + DurableAgentStateUsage usage = Assert.IsType(response.Usage); + + Assert.Equal("root", state.ExtensionData?["rootMetadata"].GetString()); + Assert.Equal(1, state.UnknownProperties?["futureRoot"].GetInt32()); + Assert.Equal("data", state.Data.ExtensionData?["dataMetadata"].GetString()); + Assert.Equal(2, state.Data.UnknownProperties?["futureData"].GetInt32()); + Assert.Equal("entry", response.ExtensionData?["entryMetadata"].GetString()); + Assert.Equal(3, response.UnknownProperties?["futureEntry"].GetInt32()); + Assert.Equal("message", message.AdditionalProperties?["messageMetadata"].GetString()); + Assert.Equal(6, message.UnknownProperties?["futureMessage"].GetInt32()); + Assert.Equal(7, content.UnknownProperties?["futureContent"].GetInt32()); + Assert.Equal(4, usage.ExtensionData?["providerCount"].GetInt32()); + Assert.Equal(5, usage.UnknownProperties?["futureUsage"].GetInt32()); + + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement root = document.RootElement; + JsonElement data = root.GetProperty("data"); + JsonElement entry = data.GetProperty("conversationHistory")[0]; + JsonElement roundTrippedMessage = entry.GetProperty("messages")[0]; + JsonElement roundTrippedContent = roundTrippedMessage.GetProperty("contents")[0]; + JsonElement roundTrippedUsage = entry.GetProperty("usage"); + + Assert.Equal("root", root.GetProperty("extensionData").GetProperty("rootMetadata").GetString()); + Assert.Equal(1, root.GetProperty("futureRoot").GetInt32()); + Assert.Equal("data", data.GetProperty("extensionData").GetProperty("dataMetadata").GetString()); + Assert.Equal(2, data.GetProperty("futureData").GetInt32()); + Assert.Equal("entry", entry.GetProperty("extensionData").GetProperty("entryMetadata").GetString()); + Assert.Equal(3, entry.GetProperty("futureEntry").GetInt32()); + Assert.Equal( + "message", + roundTrippedMessage.GetProperty("extensionData").GetProperty("messageMetadata").GetString()); + Assert.Equal(6, roundTrippedMessage.GetProperty("futureMessage").GetInt32()); + Assert.Equal(7, roundTrippedContent.GetProperty("futureContent").GetInt32()); + Assert.Equal(4, roundTrippedUsage.GetProperty("extensionData").GetProperty("providerCount").GetInt32()); + Assert.Equal(5, roundTrippedUsage.GetProperty("futureUsage").GetInt32()); + } + [Fact] public void BasicState() { @@ -117,7 +352,7 @@ public void BasicState() "createdAt": "2024-01-01T12:01:00Z", "messages": [ { - "role": "agent", + "role": "assistant", "contents": [ { "$type": "text", @@ -160,11 +395,208 @@ public void BasicState() Assert.Equal("12345", entry.CorrelationId); Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:01:00Z"), entry.CreatedAt); Assert.Single(entry.Messages); - Assert.Equal("agent", entry.Messages[0].Role); + Assert.Equal("assistant", entry.Messages[0].Role); Assert.Single(entry.Messages[0].Contents); DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents); DurableAgentStateTextContent textContent = Assert.IsType(content); Assert.Equal("Hi user!", textContent.Text); }); } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.0")] + public void CloneForWritePromotesOlderCompatibleStateToCurrentVersion(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [], + "ingestedPositions": { "writer": 2 } + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState promoted = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + promoted, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, promoted.SchemaVersion); + Assert.Equal(2, promoted.Data.IngestedPositions?["writer"]); + Assert.Contains("\"schemaVersion\":\"1.2.0\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void CloneForWritePreservesCurrentVersion() + { + const string Version = "1.2.0"; + string json = $$""" + { + "schemaVersion": "{{Version}}", + "data": { + "conversationHistory": [] + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState clone = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + clone, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(Version, clone.SchemaVersion); + Assert.Contains($"\"schemaVersion\":\"{Version}\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void CurrentVersionUnknownFieldsSurviveMutationAndRoundTrip() + { + const string JsonText = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [ + { + "$type": "response", + "correlationId": "future", + "createdAt": "2026-09-06T12:00:00+00:00", + "futureEntry": { "keep": true }, + "messages": [ + { + "role": "assistant", + "contents": [], + "futureMessage": [1, 2, 3] + } + ] + } + ], + "futureData": "preserve" + }, + "futureRoot": 42 + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState mutated = state.Clone(); + mutated.Data.IngestedPositions = new Dictionary { ["writer"] = 7 }; + string roundTrip = JsonSerializer.Serialize( + mutated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("1.2.0", document.RootElement.GetProperty("schemaVersion").GetString()); + Assert.Equal(42, document.RootElement.GetProperty("futureRoot").GetInt32()); + JsonElement data = document.RootElement.GetProperty("data"); + Assert.Equal("preserve", data.GetProperty("futureData").GetString()); + Assert.Equal(7, data.GetProperty("ingestedPositions").GetProperty("writer").GetInt32()); + JsonElement response = data.GetProperty("conversationHistory")[0]; + Assert.True(response.GetProperty("futureEntry").GetProperty("keep").GetBoolean()); + Assert.Equal(3, response.GetProperty("messages")[0].GetProperty("futureMessage").GetArrayLength()); + } + + [Fact] + public void SharedPythonShapeFixtureMigratesIdsAndPreservesExtensions() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "shared-durable-agent-state-1.2-python-shape.json")); + using JsonDocument sourceDocument = JsonDocument.Parse(json); + JsonElement sourceUnknownContent = sourceDocument.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState migrated = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + migrated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("producer-request-id", migrated.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal("python-metadata-only", migrated.Data.ConversationHistory[1].Messages[0].MessageId); + Assert.Equal("durable_response_corr-python_1", migrated.Data.ConversationHistory[1].Messages[1].MessageId); + Assert.Equal("durable_response_corr-python_2", migrated.Data.ConversationHistory[1].Messages[2].MessageId); + Assert.Equal("python-unknown-content", migrated.Data.ConversationHistory[1].Messages[3].MessageId); + Assert.Equal("durable_errorResponse_corr-error_0", migrated.Data.ConversationHistory[2].Messages[0].MessageId); + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + migrated.Data.ConversationHistory[3].Messages[0].MessageId); + DurableAgentStateResponse response = + Assert.IsType(migrated.Data.ConversationHistory[1]); + ChatMessage metadataOnly = response.ToResponse().Messages[0]; + Assert.Empty(metadataOnly.Contents); + Assert.Equal("python-metadata-only", metadataOnly.MessageId); + Assert.Equal("python-agent", metadataOnly.AuthorName); + Assert.Equal( + DateTimeOffset.Parse("2026-07-27T12:34:51+00:00"), + metadataOnly.CreatedAt); + Assert.Equal( + "python", + Assert.IsType(metadataOnly.AdditionalProperties?["metadataOrigin"]).GetString()); + DurableAgentStateUnknownContent unknown = Assert.IsType( + migrated.Data.ConversationHistory[1].Messages[3].Contents[0]); + JsonElement pythonContent = unknown.Content; + Assert.Equal( + "python-owned-user-field", + pythonContent.GetProperty("$runtimeType").GetString()); + Assert.Equal("future_python_content", pythonContent.GetProperty("type").GetString()); + Assert.Equal("python-value", pythonContent.GetProperty("payload").GetString()); + Assert.Equal( + 3, + pythonContent.GetProperty("future_payload").GetProperty("nested").GetArrayLength()); + JsonElement persistedUnknownContent = document.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + Assert.True(JsonElement.DeepEquals(sourceUnknownContent, persistedUnknownContent)); + UsageDetails usage = Assert.IsType(response.ToResponse().Usage); + Assert.Equal(7, usage.AdditionalCounts?["providerCount"]); + Assert.Equal(11, usage.AdditionalCounts?["futureNumeric"]); + Assert.DoesNotContain("futureString", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureObject", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureArray", usage.AdditionalCounts?.Keys ?? []); + Assert.Contains("\"futureString\":\"seven\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureObject\":{\"count\":8}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureArray\":[9]", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"type\":\"future_python_content\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"payload\":\"python-value\"", roundTrip, StringComparison.Ordinal); + Assert.Equal(3, migrated.Data.IngestedPositions?["writer"]); + Assert.Equal("interop-fixture", migrated.ExtensionData?["rootProducer"].GetString()); + Assert.True(migrated.UnknownProperties?["futureRootProperty"].GetProperty("preserve").GetBoolean()); + Assert.Equal("python", migrated.Data.ExtensionData?["dataProducer"].GetString()); + Assert.True(migrated.Data.UnknownProperties?["futureDataProperty"].GetProperty("preserve").GetBoolean()); + Assert.True(document.RootElement.TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.TryGetProperty("futureRootProperty", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("futureDataProperty", out _)); + } + + [Fact] + public void OptionalRequestPropertiesAreOmittedWhenAbsent() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + }); + + string json = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.DoesNotContain("\"orchestrationId\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"responseType\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"expirationTimeUtc\"", json, StringComparison.Ordinal); + } }