// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; public class StateManagerTests { [Fact] public async Task Test_SharedScope_ReadKeysAsync() { const string? ScopeName = "sharedScope"; await RunScopeKeysTestAsync(ScopeName, isSharedScope: true); } [Fact] public async Task Test_PrivateScope_ReadKeysAsync() { const string? ScopeName = null; await RunScopeKeysTestAsync(ScopeName, isSharedScope: false); } private static async Task RunScopeKeysTestAsync(string? scopeName, bool isSharedScope) { const string SelfExecutorId = "executor1"; const string OtherExecutorId = "executor2"; const string Key1 = "key1"; HashSet ExpectedAfterWrite = [Key1]; StateManager manager = new(); ScopeId sharedScopeSelfView = new(SelfExecutorId, scopeName); ScopeId sharedScopeOtherView = new(OtherExecutorId, scopeName); // Assert baseline: neither executor sees any keys HashSet selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView); selfKeys.Should().BeEmpty("there should be no keys in an empty StateManager"); HashSet otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView); otherKeys.Should().BeEmpty("there should be no keys in an empty StateManager"); // Act 1: Write a key from the self executor's view of the shared scope await manager.WriteStateAsync(sharedScopeSelfView, Key1, "value1"); // Assert 1: The self executor should see the key immediately, but the other executor should not selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView); selfKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("writes should be visible immediately to the writing executor"); otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView); otherKeys.Should().BeEmpty(isSharedScope ? "writes should not be visible to other executors until published" : "writes to private scopes should not be visible across executors"); // Act 2: Publish the updates await manager.PublishUpdatesAsync(tracer: null); // Assert 2: Both executors should see the key now, if sharedScope selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView); selfKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("published writes should be visible to all executors"); otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView); if (isSharedScope) { otherKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("published writes should be visible to all executors"); } else { otherKeys.Should().BeEmpty("writes to private scopes should not be visible across executors"); } // Act 3: Clear the state from the self executor's view of the shared scope await manager.WriteStateAsync(sharedScopeSelfView, Key1, null); // Assert 3: The self executor should not see the key immediately, but the other executor should still see it if sharedScope selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView); selfKeys.Should().BeEmpty("deletes should be visible immediately to the writing executor"); otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView); if (isSharedScope) { otherKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("published writes should be visible to all executors"); } else { otherKeys.Should().BeEmpty("writes to private scopes should not be visible across executors"); } // Act 4: Publish the updates await manager.PublishUpdatesAsync(tracer: null); // Assert 4: Neither executor should see the key now selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView); selfKeys.Should().BeEmpty("published deletes should be visible to all executors"); otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView); otherKeys.Should().BeEmpty(isSharedScope ? "published deletes should be visible to all executors" : "writes to private scopes should not be visible across executors"); } [Fact] public async Task Test_SharedScope_ValueLifecycleAsync() { const string? ScopeName = "sharedScope"; await RunValueLifecycleTestAsync(ScopeName, isSharedScope: true); } [Fact] public async Task Test_PrivateScope_ValueLifecycleAsync() { const string? ScopeName = null; await RunValueLifecycleTestAsync(ScopeName, isSharedScope: false); } private static async Task RunValueLifecycleTestAsync(string? scopeName, bool isSharedScope) { const string SelfExecutorId = "executor1"; const string OtherExecutorId = "executor2"; const string Key1 = "key1", Key2 = "key2"; const string Value1 = "value1", Value2 = "value2"; StateManager manager = new(); ScopeId scopeSelfView = new(SelfExecutorId, scopeName); ScopeId scopeOtherView = new(OtherExecutorId, scopeName); isSharedScope.Should().Be(scopeSelfView == scopeOtherView); // Assert baseline: neither executor sees any keys or values string? selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); string? selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); selfValue1.Should().BeNull("there should be no values in an empty StateManager"); selfValue2.Should().BeNull("there should be no values in an empty StateManager"); string? otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); string? otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); otherValue1.Should().BeNull("there should be no values in an empty StateManager"); otherValue2.Should().BeNull("there should be no values in an empty StateManager"); // Act 1: Write a value from the self executor's view of the shared scope await manager.WriteStateAsync(scopeSelfView, Key1, Value1); // Assert 1: The self executor should see the value immediately, but the other executor should not selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); selfValue1.Should().Be(Value1, "writes should be visible immediately to the writing executor"); selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); selfValue2.Should().BeNull("uninvolved keys' state/value should not change after a write"); otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); otherValue1.Should().BeNull(isSharedScope ? "writes should not be visible to other executors until published (key1: written by self, read by other)" : "writes to private scopes should not be visible across executors"); otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); otherValue2.Should().BeNull("uninvolved keys' state/value should not change after a write"); // Act 2: Write a value from the other executor's view of the shared scope await manager.WriteStateAsync(scopeOtherView, Key2, Value2); // Assert 2: The other executor should see the value immediately, but the self executor should not selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); selfValue1.Should().Be(Value1, "uninvolved keys' state/value should not change after a write"); selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); selfValue2.Should().BeNull(isSharedScope ? "writes should not be visible to other executors until published (key2: written by other, read by self)" : "writes to private scopes should not be visible across executors"); otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); otherValue1.Should().BeNull(isSharedScope ? "writes should not be visible to other executors until published (key1: written by self, read by other)" : "writes to private scopes should not be visible across executors"); otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); otherValue2.Should().Be(Value2, "writes should be visible immediately to the writing executor"); // Act 3: Publish the updates await manager.PublishUpdatesAsync(tracer: null); // Assert 3: Both executors should see both values now, if the scope is shared selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); selfValue1.Should().Be(Value1, "published writes should be visible to all executors (key1: written by self, read by self)"); selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); if (isSharedScope) { selfValue2.Should().Be(Value2, "published writes should be visible to all executors (key2: written by other, read by self)"); } else { selfValue2.Should().BeNull("writes to private scopes should not be visible across executors"); } otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); if (isSharedScope) { otherValue1.Should().Be(Value1, "published writes should be visible to all executors (key1: written by self, read by other)"); } else { otherValue1.Should().BeNull("writes to private scopes should not be visible across executors"); } otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); otherValue2.Should().Be(Value2, "published writes should be visible to all executors (key2: written by other, read by other)"); // Act 4: Clear the value from the self executor's view of the shared scope await manager.ClearStateAsync(scopeSelfView); // Assert 4: The self executor should not see either value immediately, but the other executor should still see both selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); selfValue1.Should().BeNull("clears should be visible immediately to the writing executor"); selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); selfValue2.Should().BeNull(isSharedScope ? "clears should be visible immediately to the writing executor" : "writes to private scopes should not be visible across executors"); otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); if (isSharedScope) { otherValue1.Should().Be(Value1, "clears should not be visible to other executors until published (key2: written by self, read by other)"); } else { otherValue1.Should().BeNull("writes to private scopes should not be visible across executors"); } otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); otherValue2.Should().Be(Value2, isSharedScope ? "clears should not be visible to other executors until published (key2: written by self, read by other)" : "writes to private scopes should not be visible across executors"); // Act 5: Publish the updates await manager.PublishUpdatesAsync(tracer: null); // Assert 5: Neither executor should see either value now selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); selfValue1.Should().BeNull("published clears should be visible to all executors"); selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); selfValue2.Should().BeNull(isSharedScope ? "published clears should be visible to all executors" : "writes to private scopes should not be visible across executors"); otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); otherValue1.Should().BeNull(isSharedScope ? "published clears should be visible to all executors" : "writes to private scopes should not be visible across executors"); otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); if (isSharedScope) { otherValue2.Should().BeNull("published clears should be visible to all executors"); } else { otherValue2.Should().Be(Value2, "writes to private scopes should not be visible across executors"); } // Restore the written state of both keys await manager.WriteStateAsync(scopeSelfView, Key1, Value1); await manager.WriteStateAsync(scopeOtherView, Key2, Value2); await manager.PublishUpdatesAsync(tracer: null); // Act 6: Delete Key1 from the other executor's view of the shared scope await manager.WriteStateAsync(scopeOtherView, Key1, null); // Assert 6: The other executor should not see Key1 immediately, but should still see Key2. The self executor should still see both. selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); selfValue1.Should().Be(Value1, isSharedScope ? "deletes should not be visible to other executors until published (key1: written by other, read by self)" : "writes to private scopes should not be visible across executors"); selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); if (isSharedScope) { selfValue2.Should().Be(Value2, "uninvolved keys' state/value should not change after a delete"); } else { selfValue2.Should().BeNull("writes to private scopes should not be visible across executors"); } otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); otherValue1.Should().BeNull(isSharedScope ? "deletes should be visible immediately to the writing executor" : "writes to private scopes should not be visible across executors"); otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); otherValue2.Should().Be(Value2, "uninvolved keys' state/value should not change after a delete"); // Act 7: Delete Key2 from the self executor's view of the shared scope await manager.WriteStateAsync(scopeSelfView, Key2, null); // Assert 7: The self executor should not see Key2 immediately, but should still see Key1. // The other executor should not see Key1, but should still see Key2. selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); selfValue1.Should().Be(Value1, isSharedScope ? "deletes should not be visible to other executors until published (key1: written by other, read by self)" : "writes to private scopes should not be visible across executors"); selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); selfValue2.Should().BeNull(isSharedScope ? "deletes should be visible immediately to the writing executor" : "writes to private scopes should not be visible across executors"); otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); otherValue1.Should().BeNull(isSharedScope ? "deletes should be visible immediately to the writing executor" : "writes to private scopes should not be visible across executors"); otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); otherValue2.Should().Be(Value2, isSharedScope ? "deletes should not be visible to other executors until published (key2: written by self, read by other)" : "writes to private scopes should not be visible across executors"); // Act 8: Publish the updates await manager.PublishUpdatesAsync(tracer: null); // Assert 8: Neither executor should see either value now selfValue1 = await manager.ReadStateAsync(scopeSelfView, Key1); if (isSharedScope) { selfValue1.Should().BeNull("published deletes should be visible to all executors"); } else { selfValue1.Should().Be(Value1, "writes to private scopes should not be visible across executors"); } selfValue2 = await manager.ReadStateAsync(scopeSelfView, Key2); selfValue2.Should().BeNull(isSharedScope ? "published deletes should be visible to all executors" : "writes to private scopes should not be visible across executors"); otherValue1 = await manager.ReadStateAsync(scopeOtherView, Key1); otherValue1.Should().BeNull(isSharedScope ? "published deletes should be visible to all executors" : "writes to private scopes should not be visible across executors"); otherValue2 = await manager.ReadStateAsync(scopeOtherView, Key2); if (isSharedScope) { otherValue2.Should().BeNull("published deletes should be visible to all executors"); } else { otherValue2.Should().Be(Value2, "writes to private scopes should not be visible across executors"); } } [Fact] public async Task Test_SharedScope_ConflictingUpdatesAsync() { const string? ScopeName = "sharedScope"; await RunConflictingUpdatesTest_WriteVsWriteAsync(ScopeName, isSharedScope: true); await RunConflictingUpdatesTest_WriteVsDeleteAsync(ScopeName, isSharedScope: true); await RunConflictingUpdatesTest_WriteVsClearAsync(ScopeName, isSharedScope: true); } [Fact] public async Task Test_PrivateScope_ConflictingUpdatesAsync() { const string? ScopeName = null; await RunConflictingUpdatesTest_WriteVsWriteAsync(ScopeName, isSharedScope: false); await RunConflictingUpdatesTest_WriteVsDeleteAsync(ScopeName, isSharedScope: false); await RunConflictingUpdatesTest_WriteVsClearAsync(ScopeName, isSharedScope: false); } private static async Task RunConflictingUpdatesTest_WriteVsWriteAsync(string? scopeName, bool isSharedScope) { const string SelfExecutorId = "executor1"; const string OtherExecutorId = "executor2"; const string Key1 = "key1"; const string Value1 = "value", Value2 = "value"; // Arrange StateManager manager = new(); ScopeId scopeSelfView = new(SelfExecutorId, scopeName); ScopeId scopeOtherView = new(OtherExecutorId, scopeName); isSharedScope.Should().Be(scopeSelfView == scopeOtherView); // Act 1: Write a conflicting value from the self executor's view of the shared scope // Note that conflicting means update to the same key, not that the values are necessarily different. // We do not have any logic to resolve equivalent updates from different executors as idempotent. await manager.WriteStateAsync(scopeSelfView, Key1, Value1); await manager.WriteStateAsync(scopeOtherView, Key1, Value2); Func act = async () => await manager.PublishUpdatesAsync(tracer: null); if (isSharedScope) { await act.Should().ThrowAsync("conflicting writes to the same key should raise an exception when published"); } else { await act.Should().NotThrowAsync("writes to private scopes should not be visible across executors"); } } private static async Task RunConflictingUpdatesTest_WriteVsDeleteAsync(string? scopeName, bool isSharedScope) { const string SelfExecutorId = "executor1"; const string OtherExecutorId = "executor2"; const string Key1 = "key1", Key2 = "key2"; const string Value1 = "value", Value2 = "value"; // Arrange StateManager manager = new(); ScopeId scopeSelfView = new(SelfExecutorId, scopeName); ScopeId scopeOtherView = new(OtherExecutorId, scopeName); isSharedScope.Should().Be(scopeSelfView == scopeOtherView); await manager.WriteStateAsync(scopeSelfView, Key1, Value1); await manager.WriteStateAsync(scopeOtherView, Key2, Value2); await manager.PublishUpdatesAsync(tracer: null); // Act: Update the key from one executor and delete it from another await manager.WriteStateAsync(scopeSelfView, Key1, "newValue"); await manager.ClearStateAsync(scopeOtherView, Key1); Func act = async () => await manager.PublishUpdatesAsync(tracer: null); if (isSharedScope) { await act.Should().ThrowAsync("conflicting writes (update vs delete) should raise an exception when published"); } else { await act.Should().NotThrowAsync("writes to private scopes should not be visible across executors"); } } private static async Task RunConflictingUpdatesTest_WriteVsClearAsync(string? scopeName, bool isSharedScope) { const string SelfExecutorId = "executor1"; const string OtherExecutorId = "executor2"; const string Key1 = "key1", Key2 = "key2"; const string Value1 = "value", Value2 = "value"; // Arrange StateManager manager = new(); ScopeId scopeSelfView = new(SelfExecutorId, scopeName); ScopeId scopeOtherView = new(OtherExecutorId, scopeName); isSharedScope.Should().Be(scopeSelfView == scopeOtherView); await manager.WriteStateAsync(scopeSelfView, Key1, Value1); await manager.WriteStateAsync(scopeOtherView, Key2, Value2); await manager.PublishUpdatesAsync(tracer: null); // Act: Update the key from one, and clear the entire scope from another await manager.WriteStateAsync(scopeSelfView, Key1, "newValue"); await manager.ClearStateAsync(scopeOtherView); Func act = async () => await manager.PublishUpdatesAsync(tracer: null); // Assert if (isSharedScope) { await act.Should().ThrowAsync("conflicting writes (update vs clear) should raise an exception when published"); } else { await act.Should().NotThrowAsync("writes to private scopes should not be visible across executors"); } } private static void VerifyIs(PortableValue? candidatePV, TExpectedType value) { candidatePV.Should().NotBeNull(); candidatePV.Is(out TExpectedType? candidateValue).Should().BeTrue(); candidateValue.Should().Be(value); } private static void VerifyIsNot(PortableValue? candidatePV) { candidatePV.Should().NotBeNull(); candidatePV.Is(out TExpectedType? _).Should().BeFalse(); } [Theory] [InlineData(true)] [InlineData(false)] public async Task Test_LoadPortableValueStateAsync(bool publishStateUpdates) { ScopeId scope = new("executor1"); const string StringValue = "string"; const int IntValue = 42; ScopeKey ScopeKey = new("executor1", "scope", "key"); PortableValue PortableValueValue = new(StringValue); // Arrange StateManager manager = new(); await manager.WriteStateAsync(scope, nameof(StringValue), StringValue); await manager.WriteStateAsync(scope, nameof(IntValue), IntValue); await manager.WriteStateAsync(scope, nameof(ScopeKey), ScopeKey); await manager.WriteStateAsync(scope, nameof(PortableValueValue), PortableValueValue); if (publishStateUpdates) { await manager.PublishUpdatesAsync(tracer: null); } // Act & Assert - Read as the original types PortableValue? stringAsPV = await manager.ReadStateAsync(scope, nameof(StringValue)); VerifyIs(stringAsPV, StringValue); VerifyIsNot(stringAsPV); VerifyIsNot(stringAsPV); VerifyIsNot(stringAsPV); PortableValue? intAsPV = await manager.ReadStateAsync(scope, nameof(IntValue)); VerifyIsNot(intAsPV); VerifyIs(intAsPV, IntValue); VerifyIsNot(intAsPV); VerifyIsNot(intAsPV); PortableValue? scopeKeyAsPV = await manager.ReadStateAsync(scope, nameof(ScopeKey)); VerifyIsNot(scopeKeyAsPV); VerifyIsNot(scopeKeyAsPV); VerifyIs(scopeKeyAsPV, ScopeKey); VerifyIsNot(scopeKeyAsPV); PortableValue? pvAsPV = await manager.ReadStateAsync(scope, nameof(PortableValueValue)); VerifyIs(pvAsPV, StringValue); VerifyIsNot(pvAsPV); VerifyIsNot(pvAsPV); // Check that we don't double-wrap stored PortableValues on the out path VerifyIsNot(pvAsPV); } [Fact] public async Task Test_LoadPortableValueState_AfterSerializationAsync() { ScopeId scope = new("executor1"); const string StringValue = "string"; const int IntValue = 42; ScopeKey ScopeKey = new("executor1", "scope", "key"); PortableValue PortableValueValue = new(StringValue); // Arrange StateManager manager = new(); await manager.WriteStateAsync(scope, nameof(StringValue), StringValue); await manager.WriteStateAsync(scope, nameof(IntValue), IntValue); await manager.WriteStateAsync(scope, nameof(ScopeKey), ScopeKey); await manager.WriteStateAsync(scope, nameof(PortableValueValue), PortableValueValue); await manager.PublishUpdatesAsync(tracer: null); Dictionary exportedState = await manager.ExportStateAsync(); Dictionary serializedState = JsonSerializationTests.RunJsonRoundtrip(exportedState); Checkpoint testCheckpoint = new(0, JsonSerializationTests.CreateTestWorkflowInfo(), new([], [], []), serializedState, []); manager = new(); await manager.ImportStateAsync(testCheckpoint); // Act & Assert - Read as the original types PortableValue? stringAsPV = await manager.ReadStateAsync(scope, nameof(StringValue)); VerifyIs(stringAsPV, StringValue); VerifyIsNot(stringAsPV); VerifyIsNot(stringAsPV); PortableValue? intAsPV = await manager.ReadStateAsync(scope, nameof(IntValue)); VerifyIsNot(intAsPV); VerifyIs(intAsPV, IntValue); VerifyIsNot(intAsPV); PortableValue? scopeKeyAsPV = await manager.ReadStateAsync(scope, nameof(ScopeKey)); VerifyIsNot(scopeKeyAsPV); VerifyIsNot(scopeKeyAsPV); VerifyIs(scopeKeyAsPV, ScopeKey); VerifyIsNot(scopeKeyAsPV); PortableValue? pvAsPV = await manager.ReadStateAsync(scope, nameof(PortableValueValue)); VerifyIs(pvAsPV, StringValue); VerifyIsNot(pvAsPV); VerifyIsNot(pvAsPV); // Check that we don't double-wrap stored PortableValues on the out path VerifyIsNot(pvAsPV); } }