chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class AgentSessionIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseValidSessionId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
string sessionIdString = $"@dafx-{Name}@{Key}";
|
||||
AgentSessionId sessionId = AgentSessionId.Parse(sessionIdString);
|
||||
|
||||
Assert.Equal(Name, sessionId.Name);
|
||||
Assert.Equal(Key, sessionId.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseInvalidSessionId()
|
||||
{
|
||||
const string InvalidSessionIdString = "@test-agent@12345"; // Missing "dafx-" prefix
|
||||
Assert.Throws<ArgumentException>(() => AgentSessionId.Parse(InvalidSessionIdString));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEntityId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
|
||||
EntityInstanceId entityId = new($"dafx-{Name}", Key);
|
||||
AgentSessionId sessionId = (AgentSessionId)entityId;
|
||||
|
||||
Assert.Equal(Name, sessionId.Name);
|
||||
Assert.Equal(Key, sessionId.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromInvalidEntityId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
|
||||
EntityInstanceId entityId = new(Name, Key); // Missing "dafx-" prefix
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
// This assignment should throw an exception because
|
||||
// the entity ID is not a valid agent session ID.
|
||||
AgentSessionId sessionId = entityId;
|
||||
});
|
||||
}
|
||||
|
||||
// Ensures the 2-arg constructor treats the key as opaque and never re-interprets
|
||||
// it as a serialized session id, so the resulting Name always comes from the first
|
||||
// argument regardless of the key's shape.
|
||||
[Fact]
|
||||
public void ConstructorTreatsKeyAsOpaqueValue()
|
||||
{
|
||||
AgentSessionId sessionId = new("agentA", "@dafx-agentB@some-key");
|
||||
|
||||
Assert.Equal("agentA", sessionId.Name);
|
||||
Assert.Equal("@dafx-agentB@some-key", sessionId.Key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class DurableAIAgentProxyTests
|
||||
{
|
||||
// Verifies the proxy rejects a session whose agent name differs from its own,
|
||||
// and that the durable client is never called when this happens.
|
||||
[Fact]
|
||||
public async Task RunAsync_ThrowsWhenSessionBelongsToDifferentAgentAsync()
|
||||
{
|
||||
StubDurableAgentClient client = new();
|
||||
DurableAIAgentProxy proxy = new("agentA", client);
|
||||
DurableAgentSession session = new(new AgentSessionId("agentB", "shared-key"));
|
||||
|
||||
ArgumentException ex = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session));
|
||||
|
||||
Assert.Equal("session", ex.ParamName);
|
||||
Assert.Contains("agentB", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("agentA", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(0, client.CallCount);
|
||||
}
|
||||
|
||||
// Control test: when the session's agent name matches the proxy's name,
|
||||
// the request is forwarded to the durable client.
|
||||
[Fact]
|
||||
public async Task RunAsync_AllowsSessionWhenAgentNameMatchesAsync()
|
||||
{
|
||||
AgentSessionId sessionId = new("agentA", "shared-key");
|
||||
InvalidOperationException sentinel = new("reached the client");
|
||||
StubDurableAgentClient client = new() { Throw = sentinel };
|
||||
DurableAIAgentProxy proxy = new("agentA", client);
|
||||
DurableAgentSession session = new(sessionId);
|
||||
|
||||
// Reaching the durable client (and therefore propagating the sentinel) proves the
|
||||
// name-matching guard accepted this session.
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session));
|
||||
|
||||
Assert.Same(sentinel, ex);
|
||||
Assert.Equal(1, client.CallCount);
|
||||
Assert.Equal(sessionId, client.LastSessionId);
|
||||
}
|
||||
|
||||
// Ensures the agent-name comparison is case-insensitive, so casing differences
|
||||
// are neither a false-positive rejection nor a bypass.
|
||||
[Fact]
|
||||
public async Task RunAsync_AgentNameComparisonIsCaseInsensitiveAsync()
|
||||
{
|
||||
AgentSessionId sessionId = new("AGENTA", "shared-key");
|
||||
InvalidOperationException sentinel = new("reached the client");
|
||||
StubDurableAgentClient client = new() { Throw = sentinel };
|
||||
DurableAIAgentProxy proxy = new("agentA", client);
|
||||
DurableAgentSession session = new(sessionId);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session));
|
||||
|
||||
Assert.Same(sentinel, ex);
|
||||
Assert.Equal(1, client.CallCount);
|
||||
}
|
||||
|
||||
private sealed class StubDurableAgentClient : IDurableAgentClient
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public AgentSessionId LastSessionId { get; private set; }
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
public Task<AgentRunHandle> RunAgentAsync(
|
||||
AgentSessionId sessionId,
|
||||
RunRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this.CallCount++;
|
||||
this.LastSessionId = sessionId;
|
||||
if (this.Throw is not null)
|
||||
{
|
||||
return Task.FromException<AgentRunHandle>(this.Throw);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Test did not configure a response.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DurableAgentRunOptions"/> class.
|
||||
/// </summary>
|
||||
public sealed class DurableAgentRunOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CloneReturnsNewInstanceWithSameValues()
|
||||
{
|
||||
// Arrange
|
||||
DurableAgentRunOptions options = new()
|
||||
{
|
||||
EnableToolCalls = false,
|
||||
EnableToolNames = new List<string> { "tool1", "tool2" },
|
||||
IsFireAndForget = true,
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = 42
|
||||
},
|
||||
ResponseFormat = ChatResponseFormat.Json
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentRunOptions cloneAsBase = options.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cloneAsBase);
|
||||
Assert.IsType<DurableAgentRunOptions>(cloneAsBase);
|
||||
DurableAgentRunOptions clone = (DurableAgentRunOptions)cloneAsBase;
|
||||
Assert.NotSame(options, clone);
|
||||
Assert.Equal(options.EnableToolCalls, clone.EnableToolCalls);
|
||||
Assert.NotNull(clone.EnableToolNames);
|
||||
Assert.NotSame(options.EnableToolNames, clone.EnableToolNames);
|
||||
Assert.Equal(2, clone.EnableToolNames.Count);
|
||||
Assert.Contains("tool1", clone.EnableToolNames);
|
||||
Assert.Contains("tool2", clone.EnableToolNames);
|
||||
Assert.Equal(options.IsFireAndForget, clone.IsFireAndForget);
|
||||
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
|
||||
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
|
||||
Assert.NotNull(clone.AdditionalProperties);
|
||||
Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties);
|
||||
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
|
||||
Assert.Equal(42, clone.AdditionalProperties["key2"]);
|
||||
Assert.Same(options.ResponseFormat, clone.ResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentEnableToolNamesList()
|
||||
{
|
||||
// Arrange
|
||||
DurableAgentRunOptions options = new()
|
||||
{
|
||||
EnableToolNames = new List<string> { "tool1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone();
|
||||
clone.EnableToolNames!.Add("tool2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, clone.EnableToolNames.Count);
|
||||
Assert.Single(options.EnableToolNames);
|
||||
Assert.DoesNotContain("tool2", options.EnableToolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
|
||||
{
|
||||
// Arrange
|
||||
DurableAgentRunOptions options = new()
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone();
|
||||
clone.AdditionalProperties!["key2"] = "value2";
|
||||
|
||||
// Assert
|
||||
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
|
||||
Assert.False(options.AdditionalProperties.ContainsKey("key2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class DurableAgentSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuiltInSerialization()
|
||||
{
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
DurableAgentSession session = new(sessionId);
|
||||
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
|
||||
// Expected format: "{\"sessionId\":\"@dafx-test-agent@<random-key>\"}"
|
||||
string expectedSerializedSession = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\",\"stateBag\":{{}}}}";
|
||||
Assert.Equal(expectedSerializedSession, serializedSession.ToString());
|
||||
|
||||
DurableAgentSession deserializedSession = DurableAgentSession.Deserialize(serializedSession);
|
||||
Assert.Equal(sessionId, deserializedSession.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void STJSerialization()
|
||||
{
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
AgentSession session = new DurableAgentSession(sessionId);
|
||||
|
||||
// Need to specify the type explicitly because STJ, unlike other serializers,
|
||||
// does serialization based on the static type of the object, not the runtime type.
|
||||
string serializedSession = JsonSerializer.Serialize(session, typeof(DurableAgentSession));
|
||||
|
||||
// Expected format: "{\"sessionId\":\"@dafx-test-agent@<random-key>\"}"
|
||||
string expectedSerializedSession = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\",\"stateBag\":{{}}}}";
|
||||
Assert.Equal(expectedSerializedSession, serializedSession);
|
||||
|
||||
DurableAgentSession? deserializedSession = JsonSerializer.Deserialize<DurableAgentSession>(serializedSession);
|
||||
Assert.NotNull(deserializedSession);
|
||||
Assert.Equal(sessionId, deserializedSession.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInSerialization_RoundTrip_PreservesStateBag()
|
||||
{
|
||||
// Arrange
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
DurableAgentSession session = new(sessionId);
|
||||
session.StateBag.SetValue("durableKey", "durableValue");
|
||||
|
||||
// Act
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
DurableAgentSession deserializedSession = DurableAgentSession.Deserialize(serializedSession);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(sessionId, deserializedSession.SessionId);
|
||||
Assert.True(deserializedSession.StateBag.TryGetValue<string>("durableKey", out var value));
|
||||
Assert.Equal("durableValue", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void STJSerialization_RoundTrip_PreservesStateBag()
|
||||
{
|
||||
// Arrange
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
DurableAgentSession session = new(sessionId);
|
||||
session.StateBag.SetValue("stjKey", "stjValue");
|
||||
|
||||
// Act
|
||||
string serializedSession = JsonSerializer.Serialize(session, typeof(DurableAgentSession));
|
||||
DurableAgentSession? deserializedSession = JsonSerializer.Deserialize<DurableAgentSession>(serializedSession);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserializedSession);
|
||||
Assert.True(deserializedSession.StateBag.TryGetValue<string>("stjKey", out var value));
|
||||
Assert.Equal("stjValue", value);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateContentTests
|
||||
{
|
||||
private static readonly JsonTypeInfo s_stateContentTypeInfo =
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateContent))!;
|
||||
|
||||
[Fact]
|
||||
public void ErrorContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
ErrorContent errorContent = new("message")
|
||||
{
|
||||
Details = "details",
|
||||
ErrorCode = "code"
|
||||
};
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(errorContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
ErrorContent convertedErrorContent = Assert.IsType<ErrorContent>(convertedContent);
|
||||
|
||||
Assert.Equal(errorContent.Message, convertedErrorContent.Message);
|
||||
Assert.Equal(errorContent.Details, convertedErrorContent.Details);
|
||||
Assert.Equal(errorContent.ErrorCode, convertedErrorContent.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent textContent = new("Hello, world!");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FunctionCallContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
FunctionCallContent functionCallContent = new(
|
||||
"call-123",
|
||||
"MyFunction",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
{ "param1", 42 },
|
||||
{ "param2", "value" }
|
||||
});
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionCallContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
FunctionCallContent convertedFunctionCallContent = Assert.IsType<FunctionCallContent>(convertedContent);
|
||||
|
||||
Assert.Equal(functionCallContent.CallId, convertedFunctionCallContent.CallId);
|
||||
Assert.Equal(functionCallContent.Name, convertedFunctionCallContent.Name);
|
||||
|
||||
Assert.NotNull(functionCallContent.Arguments);
|
||||
Assert.NotNull(convertedFunctionCallContent.Arguments);
|
||||
Assert.Equal(functionCallContent.Arguments.Keys.Order(), convertedFunctionCallContent.Arguments.Keys.Order());
|
||||
|
||||
// NOTE: Deserialized dictionaries will have JSON element values rather than the original native types,
|
||||
// so we only check the keys here.
|
||||
foreach (string key in functionCallContent.Arguments.Keys)
|
||||
{
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(functionCallContent.Arguments[key]),
|
||||
JsonSerializer.Serialize(convertedFunctionCallContent.Arguments[key]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FunctionResultContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
FunctionResultContent functionResultContent = new("call-123", "return value");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionResultContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
FunctionResultContent convertedFunctionResultContent = Assert.IsType<FunctionResultContent>(convertedContent);
|
||||
|
||||
Assert.Equal(functionResultContent.CallId, convertedFunctionResultContent.CallId);
|
||||
// NOTE: We serialize both results to JSON for comparison since deserialized objects will be
|
||||
// JSON elements rather than the original native types.
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(functionResultContent.Result),
|
||||
JsonSerializer.Serialize(convertedFunctionResultContent.Result));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==", null)] // Valid data URI containing media type; pass null for separate mediaType parameter.
|
||||
[InlineData("data:;base64,SGVsbG8sIFdvcmxkIQ==", "text/plain")] // Valid data URI without media type; pass media
|
||||
public void DataContentSerializationDeserialization(string dataUri, string? mediaType)
|
||||
{
|
||||
// Arrange
|
||||
DataContent dataContent = new(dataUri, mediaType);
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(dataContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
DataContent convertedDataContent = Assert.IsType<DataContent>(convertedContent);
|
||||
|
||||
Assert.Equal(dataContent.Uri, convertedDataContent.Uri);
|
||||
Assert.Equal(dataContent.MediaType, convertedDataContent.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedFileContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
HostedFileContent hostedFileContent = new("file-123");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedFileContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
HostedFileContent convertedHostedFileContent = Assert.IsType<HostedFileContent>(convertedContent);
|
||||
|
||||
Assert.Equal(hostedFileContent.FileId, convertedHostedFileContent.FileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedVectorStoreContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
HostedVectorStoreContent hostedVectorStoreContent = new("vs-123");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedVectorStoreContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
HostedVectorStoreContent convertedHostedVectorStoreContent = Assert.IsType<HostedVectorStoreContent>(convertedContent);
|
||||
|
||||
Assert.Equal(hostedVectorStoreContent.VectorStoreId, convertedHostedVectorStoreContent.VectorStoreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextReasoningContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextReasoningContent textReasoningContent = new("Reasoning chain...");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textReasoningContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextReasoningContent convertedTextReasoningContent = Assert.IsType<TextReasoningContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textReasoningContent.Text, convertedTextReasoningContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UriContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
UriContent uriContent = new(new Uri("https://example.com"), "text/html");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(uriContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
UriContent convertedUriContent = Assert.IsType<UriContent>(convertedContent);
|
||||
|
||||
Assert.Equal(uriContent.Uri, convertedUriContent.Uri);
|
||||
Assert.Equal(uriContent.MediaType, convertedUriContent.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsageContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
UsageDetails usageDetails = new()
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
};
|
||||
|
||||
UsageContent usageContent = new(usageDetails);
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(usageContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
UsageContent convertedUsageContent = Assert.IsType<UsageContent>(convertedContent);
|
||||
|
||||
Assert.NotNull(convertedUsageContent.Details);
|
||||
Assert.Equal(usageDetails.InputTokenCount, convertedUsageContent.Details.InputTokenCount);
|
||||
Assert.Equal(usageDetails.OutputTokenCount, convertedUsageContent.Details.OutputTokenCount);
|
||||
Assert.Equal(usageDetails.TotalTokenCount, convertedUsageContent.Details.TotalTokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent originalContent = new("Some unknown content");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateUnknownContent.FromUnknownContent(originalContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(originalContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void MessageSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent textContent = new("Hello, world!");
|
||||
ChatMessage message = new(ChatRole.User, [textContent])
|
||||
{
|
||||
AuthorName = "User123",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
DurableAgentStateMessage durableMessage = DurableAgentStateMessage.FromChatMessage(message);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(
|
||||
durableMessage,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!);
|
||||
|
||||
DurableAgentStateMessage? convertedJsonContent = (DurableAgentStateMessage?)JsonSerializer.Deserialize(
|
||||
jsonContent,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
ChatMessage convertedMessage = convertedJsonContent.ToChatMessage();
|
||||
|
||||
Assert.Equal(message.AuthorName, convertedMessage.AuthorName);
|
||||
Assert.Equal(message.CreatedAt, convertedMessage.CreatedAt);
|
||||
Assert.Equal(message.Role, convertedMessage.Role);
|
||||
|
||||
AIContent convertedContent = Assert.Single(convertedMessage.Contents);
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void RequestSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
RunRequest originalRequest = new("Hello, world!")
|
||||
{
|
||||
OrchestrationId = "orch-456"
|
||||
};
|
||||
DurableAgentStateRequest originalDurableRequest = DurableAgentStateRequest.FromRunRequest(originalRequest);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(
|
||||
originalDurableRequest,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateRequest))!);
|
||||
|
||||
DurableAgentStateRequest? convertedJsonContent = (DurableAgentStateRequest?)JsonSerializer.Deserialize(
|
||||
jsonContent,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateRequest))!);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
Assert.Equal(originalRequest.CorrelationId, convertedJsonContent.CorrelationId);
|
||||
Assert.Equal(originalRequest.OrchestrationId, convertedJsonContent.OrchestrationId);
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromResponseDropsMessagesContainingOnlyOpaqueContent()
|
||||
{
|
||||
// Arrange: one message with real text, one with only opaque AIContent
|
||||
ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!")
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
ChatMessage opaqueOnlyMessage = new(ChatRole.Assistant, [
|
||||
new AIContent
|
||||
{
|
||||
RawRepresentation = new { kind = "sessionEvent", sessionId = "s123" }
|
||||
}])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
|
||||
};
|
||||
|
||||
AgentResponse response = new(new List<ChatMessage> { usefulMessage, opaqueOnlyMessage })
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Round-trip to verify the content is correct
|
||||
AgentResponse convertedResponse = durableResponse.ToResponse();
|
||||
ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages);
|
||||
TextContent textContent = Assert.IsType<TextContent>(Assert.Single(convertedMessage.Contents));
|
||||
Assert.Equal("Hello, world!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseKeepsMessagesWithMixedContent()
|
||||
{
|
||||
// Arrange: one message with both real text and opaque AIContent
|
||||
ChatMessage mixedMessage = new(ChatRole.Assistant, [
|
||||
new TextContent("Some useful text"),
|
||||
new AIContent { RawRepresentation = new { kind = "metadata" } }])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
AgentResponse response = new(new List<ChatMessage> { mixedMessage })
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-456", response);
|
||||
|
||||
// Assert: the message is kept because it contains at least one serializable content
|
||||
DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages);
|
||||
Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseDropsAllMessagesWhenAllAreOpaque()
|
||||
{
|
||||
// Arrange: all messages contain only opaque AIContent
|
||||
ChatMessage opaque1 = new(ChatRole.Assistant, [
|
||||
new AIContent { RawRepresentation = new { kind = "event1" } }])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
ChatMessage opaque2 = new(ChatRole.Assistant, [
|
||||
new AIContent { RawRepresentation = new { kind = "event2" } }])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
|
||||
};
|
||||
|
||||
AgentResponse response = new(new List<ChatMessage> { opaque1, opaque2 })
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response);
|
||||
|
||||
// Assert: no messages stored
|
||||
Assert.Empty(durableResponse.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseKeepsBaseAIContentWithAnnotations()
|
||||
{
|
||||
// Arrange: base AIContent with annotations should be kept
|
||||
AIContent contentWithAnnotations = new()
|
||||
{
|
||||
RawRepresentation = new { kind = "event" },
|
||||
Annotations = [new AIAnnotation() { AdditionalProperties = new() { ["cite"] = "ref-1" } }]
|
||||
};
|
||||
ChatMessage message = new(ChatRole.Assistant, [contentWithAnnotations])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-ann", response);
|
||||
|
||||
// Assert: message is kept because the AIContent has annotations
|
||||
Assert.Single(durableResponse.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseKeepsBaseAIContentWithAdditionalProperties()
|
||||
{
|
||||
// Arrange: base AIContent with additional properties should be kept
|
||||
AIContent contentWithProps = new()
|
||||
{
|
||||
RawRepresentation = new { kind = "event" },
|
||||
AdditionalProperties = new() { ["custom_key"] = "custom_value" }
|
||||
};
|
||||
ChatMessage message = new(ChatRole.Assistant, [contentWithProps])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-props", response);
|
||||
|
||||
// Assert: message is kept because the AIContent has additional properties
|
||||
Assert.Single(durableResponse.Messages);
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void InvalidVersion()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "hello"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BreakingVersion()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "2.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingData()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtraData()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [],
|
||||
"extraField": "someValue"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state?.Data?.ExtensionData);
|
||||
|
||||
Assert.True(state.Data.ExtensionData!.ContainsKey("extraField"));
|
||||
Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString());
|
||||
|
||||
// Act
|
||||
string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
JsonDocument? jsonDocument = JsonSerializer.Deserialize<JsonDocument>(jsonState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(jsonDocument);
|
||||
Assert.True(jsonDocument.RootElement.TryGetProperty("data", out JsonElement dataElement));
|
||||
Assert.True(dataElement.TryGetProperty("extraField", out JsonElement extraFieldElement));
|
||||
Assert.Equal("someValue", extraFieldElement.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicState()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [
|
||||
{
|
||||
"$type": "request",
|
||||
"correlationId": "12345",
|
||||
"createdAt": "2024-01-01T12:00:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Hello, agent!"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$type": "response",
|
||||
"correlationId": "12345",
|
||||
"createdAt": "2024-01-01T12:01:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "agent",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Hi user!"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
DurableAgentState? state = JsonSerializer.Deserialize(
|
||||
JsonText,
|
||||
DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal("1.0.0", state.SchemaVersion);
|
||||
Assert.NotNull(state.Data);
|
||||
|
||||
Assert.Collection(state.Data.ConversationHistory,
|
||||
entry =>
|
||||
{
|
||||
Assert.IsType<DurableAgentStateRequest>(entry);
|
||||
Assert.Equal("12345", entry.CorrelationId);
|
||||
Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:00:00Z"), entry.CreatedAt);
|
||||
Assert.Single(entry.Messages);
|
||||
Assert.Equal("user", entry.Messages[0].Role);
|
||||
DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateTextContent textContent = Assert.IsType<DurableAgentStateTextContent>(content);
|
||||
Assert.Equal("Hello, agent!", textContent.Text);
|
||||
},
|
||||
entry =>
|
||||
{
|
||||
Assert.IsType<DurableAgentStateResponse>(entry);
|
||||
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.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateTextContent textContent = Assert.IsType<DurableAgentStateTextContent>(content);
|
||||
Assert.Equal("Hi user!", textContent.Text);
|
||||
});
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="DurableActivityExecutor.ResolveInputType(string?, ISet{Type})"/>
|
||||
/// matches persisted assembly-qualified type-name strings against the executor's supported
|
||||
/// input types even when the persisted name carries a different assembly version, culture,
|
||||
/// or public key token than the loaded assemblies.
|
||||
/// </summary>
|
||||
public sealed class DurableActivityExecutorResolveInputTypeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveInputType_NullInput_ReturnsFirstSupportedType()
|
||||
{
|
||||
Type result = DurableActivityExecutor.ResolveInputType(null, new HashSet<Type> { typeof(int) });
|
||||
|
||||
Assert.Equal(typeof(int), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_EmptyInputAndNoSupportedTypes_FallsBackToString()
|
||||
{
|
||||
Type result = DurableActivityExecutor.ResolveInputType(string.Empty, new HashSet<Type>());
|
||||
|
||||
Assert.Equal(typeof(string), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_LoadedAssemblyQualifiedName_ReturnsSupportedType()
|
||||
{
|
||||
Type supported = typeof(ChatMessage);
|
||||
ISet<Type> supportedTypes = new HashSet<Type> { supported };
|
||||
|
||||
Type result = DurableActivityExecutor.ResolveInputType(supported.AssemblyQualifiedName, supportedTypes);
|
||||
|
||||
Assert.Same(supported, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_MutatedAssemblyVersion_ReturnsSupportedType()
|
||||
{
|
||||
Type supported = typeof(ChatMessage);
|
||||
string simpleAssemblyName = supported.Assembly.GetName().Name!;
|
||||
string mutated = $"{supported.FullName}, {simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
ISet<Type> supportedTypes = new HashSet<Type> { supported };
|
||||
|
||||
Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes);
|
||||
|
||||
Assert.Same(supported, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_MutatedGenericArgumentVersion_ReturnsSupportedType()
|
||||
{
|
||||
Type supported = typeof(List<ChatMessage>);
|
||||
string outerSimple = supported.Assembly.GetName().Name!;
|
||||
string innerSimple = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutated =
|
||||
$"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimple}, " +
|
||||
"Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]], " +
|
||||
$"{outerSimple}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
ISet<Type> supportedTypes = new HashSet<Type> { supported };
|
||||
|
||||
Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes);
|
||||
|
||||
Assert.Same(supported, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_ShortNameMatch_ReturnsSupportedType()
|
||||
{
|
||||
Type supported = typeof(ChatMessage);
|
||||
ISet<Type> supportedTypes = new HashSet<Type> { supported };
|
||||
|
||||
Type result = DurableActivityExecutor.ResolveInputType(supported.Name, supportedTypes);
|
||||
|
||||
Assert.Same(supported, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_FullNameMatch_ReturnsSupportedType()
|
||||
{
|
||||
Type supported = typeof(ChatMessage);
|
||||
ISet<Type> supportedTypes = new HashSet<Type> { supported };
|
||||
|
||||
Type result = DurableActivityExecutor.ResolveInputType(supported.FullName, supportedTypes);
|
||||
|
||||
Assert.Same(supported, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_StringFallback_ReturnsFirstSupportedTypeWhenStringUnsupported()
|
||||
{
|
||||
ISet<Type> supportedTypes = new HashSet<Type> { typeof(int) };
|
||||
|
||||
Type result = DurableActivityExecutor.ResolveInputType(typeof(string).AssemblyQualifiedName, supportedTypes);
|
||||
|
||||
Assert.Equal(typeof(int), result);
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||
|
||||
public sealed class DurableActivityExecutorTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_camelCaseOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
#region DeserializeInput
|
||||
|
||||
[Fact]
|
||||
public void DeserializeInput_StringType_ReturnsInputAsIs()
|
||||
{
|
||||
// Arrange
|
||||
const string Input = "hello world";
|
||||
|
||||
// Act
|
||||
object result = DurableActivityExecutor.DeserializeInput(Input, typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello world", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeInput_SimpleObject_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
string input = JsonSerializer.Serialize(new TestRecord("EXP-001", 100.50m), s_camelCaseOptions);
|
||||
|
||||
// Act
|
||||
object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord));
|
||||
|
||||
// Assert
|
||||
TestRecord record = Assert.IsType<TestRecord>(result);
|
||||
Assert.Equal("EXP-001", record.Id);
|
||||
Assert.Equal(100.50m, record.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeInput_StringArray_DeserializesDirectly()
|
||||
{
|
||||
// Arrange
|
||||
string input = JsonSerializer.Serialize((string[])["a", "b", "c"]);
|
||||
|
||||
// Act
|
||||
object result = DurableActivityExecutor.DeserializeInput(input, typeof(string[]));
|
||||
|
||||
// Assert
|
||||
string[] array = Assert.IsType<string[]>(result);
|
||||
Assert.Equal(["a", "b", "c"], array);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeInput_TypedArrayFromFanIn_DeserializesEachElement()
|
||||
{
|
||||
// Arrange — fan-in produces a JSON array of serialized strings
|
||||
TestRecord r1 = new("EXP-001", 100m);
|
||||
TestRecord r2 = new("EXP-002", 200m);
|
||||
string[] serializedElements =
|
||||
[
|
||||
JsonSerializer.Serialize(r1, s_camelCaseOptions),
|
||||
JsonSerializer.Serialize(r2, s_camelCaseOptions)
|
||||
];
|
||||
string input = JsonSerializer.Serialize(serializedElements);
|
||||
|
||||
// Act
|
||||
object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[]));
|
||||
|
||||
// Assert
|
||||
TestRecord[] records = Assert.IsType<TestRecord[]>(result);
|
||||
Assert.Equal(2, records.Length);
|
||||
Assert.Equal("EXP-001", records[0].Id);
|
||||
Assert.Equal(100m, records[0].Amount);
|
||||
Assert.Equal("EXP-002", records[1].Id);
|
||||
Assert.Equal(200m, records[1].Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeInput_TypedArrayWithSingleElement_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
TestRecord r1 = new("EXP-001", 50m);
|
||||
string[] serializedElements = [JsonSerializer.Serialize(r1, s_camelCaseOptions)];
|
||||
string input = JsonSerializer.Serialize(serializedElements);
|
||||
|
||||
// Act
|
||||
object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[]));
|
||||
|
||||
// Assert
|
||||
TestRecord[] records = Assert.IsType<TestRecord[]>(result);
|
||||
Assert.Single(records);
|
||||
Assert.Equal("EXP-001", records[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeInput_TypedArrayWithNullElement_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange — one element is "null"
|
||||
string input = JsonSerializer.Serialize((string[])["null"]);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeInput_InvalidJson_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
const string Input = "not valid json";
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsAny<JsonException>(
|
||||
() => DurableActivityExecutor.DeserializeInput(Input, typeof(TestRecord)));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ResolveInputType
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_NullTypeName_ReturnsFirstSupportedType()
|
||||
{
|
||||
// Arrange
|
||||
HashSet<Type> supportedTypes = [typeof(TestRecord), typeof(string)];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(TestRecord), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_EmptyTypeName_ReturnsFirstSupportedType()
|
||||
{
|
||||
// Arrange
|
||||
HashSet<Type> supportedTypes = [typeof(TestRecord)];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType(string.Empty, supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(TestRecord), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_EmptySupportedTypes_DefaultsToString()
|
||||
{
|
||||
// Arrange
|
||||
HashSet<Type> supportedTypes = [];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(string), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_MatchesByFullName()
|
||||
{
|
||||
// Arrange
|
||||
HashSet<Type> supportedTypes = [typeof(TestRecord)];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType(typeof(TestRecord).FullName, supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(TestRecord), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_MatchesByName()
|
||||
{
|
||||
// Arrange
|
||||
HashSet<Type> supportedTypes = [typeof(TestRecord)];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType("TestRecord", supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(TestRecord), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_StringArrayFallsBackToSupportedType()
|
||||
{
|
||||
// Arrange — fan-in sends string[] but executor expects TestRecord[]
|
||||
HashSet<Type> supportedTypes = [typeof(TestRecord[])];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(TestRecord[]), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_StringFallsBackToSupportedType()
|
||||
{
|
||||
// Arrange — executor doesn't support string
|
||||
HashSet<Type> supportedTypes = [typeof(TestRecord)];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType(typeof(string).FullName, supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(TestRecord), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveInputType_StringArrayRetainedWhenSupported()
|
||||
{
|
||||
// Arrange — executor explicitly supports string[]
|
||||
HashSet<Type> supportedTypes = [typeof(string[])];
|
||||
|
||||
// Act
|
||||
Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeof(string[]), result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed record TestRecord(string Id, decimal Amount);
|
||||
}
|
||||
+814
@@ -0,0 +1,814 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||
|
||||
public sealed class DurableStreamingWorkflowRunTests
|
||||
{
|
||||
private const string InstanceId = "test-instance-123";
|
||||
private const string WorkflowTestName = "TestWorkflow";
|
||||
|
||||
private static Workflow CreateTestWorkflow() =>
|
||||
new WorkflowBuilder(new FunctionExecutor<string>("start", (_, _, _) => default))
|
||||
.WithName(WorkflowTestName)
|
||||
.Build();
|
||||
|
||||
private static OrchestrationMetadata CreateMetadata(
|
||||
OrchestrationRuntimeStatus status,
|
||||
string? serializedCustomStatus = null,
|
||||
string? serializedOutput = null,
|
||||
TaskFailureDetails? failureDetails = null)
|
||||
{
|
||||
return new OrchestrationMetadata(WorkflowTestName, InstanceId)
|
||||
{
|
||||
RuntimeStatus = status,
|
||||
SerializedCustomStatus = serializedCustomStatus,
|
||||
SerializedOutput = serializedOutput,
|
||||
FailureDetails = failureDetails,
|
||||
};
|
||||
}
|
||||
|
||||
private static string SerializeCustomStatus(List<string> events)
|
||||
{
|
||||
DurableWorkflowLiveStatus status = new() { Events = events };
|
||||
return JsonSerializer.Serialize(status, DurableSerialization.Options);
|
||||
}
|
||||
|
||||
private static string SerializeCustomStatusWithPendingEvents(
|
||||
List<string> events,
|
||||
List<PendingRequestPortStatus> pendingEvents)
|
||||
{
|
||||
DurableWorkflowLiveStatus status = new() { Events = events, PendingEvents = pendingEvents };
|
||||
return JsonSerializer.Serialize(status, DurableSerialization.Options);
|
||||
}
|
||||
|
||||
private static Workflow CreateTestWorkflowWithRequestPort(string requestPortId)
|
||||
{
|
||||
FunctionExecutor<string> start = new("start", (_, _, _) => default);
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>(requestPortId);
|
||||
FunctionExecutor<string> end = new("end", (_, _, _) => default);
|
||||
return new WorkflowBuilder(start)
|
||||
.WithName(WorkflowTestName)
|
||||
.AddEdge(start, requestPort)
|
||||
.AddEdge(requestPort, end)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static string SerializeWorkflowResult(string? result, List<string> events)
|
||||
{
|
||||
DurableWorkflowResult workflowResult = new() { Result = result, Events = events };
|
||||
return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
|
||||
}
|
||||
|
||||
private static string SerializeEvent(WorkflowEvent evt)
|
||||
{
|
||||
Type eventType = evt.GetType();
|
||||
TypedPayload wrapper = new()
|
||||
{
|
||||
TypeName = eventType.AssemblyQualifiedName,
|
||||
Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options)
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload);
|
||||
}
|
||||
|
||||
#region Constructor and Properties
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsRunIdAndWorkflowName()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
|
||||
// Act
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(InstanceId, run.RunId);
|
||||
Assert.Equal(WorkflowTestName, run.WorkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NoWorkflowName_SetsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
Workflow workflow = new WorkflowBuilder(new FunctionExecutor<string>("start", (_, _, _) => default)).Build();
|
||||
|
||||
// Act
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, run.WorkflowName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStatusAsync
|
||||
|
||||
[Theory]
|
||||
[InlineData(OrchestrationRuntimeStatus.Pending, DurableRunStatus.Pending)]
|
||||
[InlineData(OrchestrationRuntimeStatus.Running, DurableRunStatus.Running)]
|
||||
[InlineData(OrchestrationRuntimeStatus.Completed, DurableRunStatus.Completed)]
|
||||
[InlineData(OrchestrationRuntimeStatus.Failed, DurableRunStatus.Failed)]
|
||||
[InlineData(OrchestrationRuntimeStatus.Terminated, DurableRunStatus.Terminated)]
|
||||
[InlineData(OrchestrationRuntimeStatus.Suspended, DurableRunStatus.Suspended)]
|
||||
|
||||
public async Task GetStatusAsync_MapsRuntimeStatusCorrectlyAsync(
|
||||
OrchestrationRuntimeStatus runtimeStatus,
|
||||
DurableRunStatus expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(runtimeStatus));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
DurableRunStatus status = await run.GetStatusAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedStatus, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusAsync_InstanceNotFound_ReturnsNotFoundAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((OrchestrationMetadata?)null);
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
DurableRunStatus status = await run.GetStatusAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(DurableRunStatus.NotFound, status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WatchStreamAsync
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_InstanceNotFound_YieldsNoEventsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((OrchestrationMetadata?)null);
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_CompletedWithResult_YieldsCompletedEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
string serializedOutput = SerializeWorkflowResult("done", []);
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(events);
|
||||
DurableWorkflowCompletedEvent completedEvent = Assert.IsType<DurableWorkflowCompletedEvent>(events[0]);
|
||||
Assert.Equal("done", completedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_CompletedWithEventsInOutput_YieldsEventsAndCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableHaltRequestedEvent haltEvent = new("executor-1");
|
||||
string serializedEvent = SerializeEvent(haltEvent);
|
||||
string serializedOutput = SerializeWorkflowResult("result", [serializedEvent]);
|
||||
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, events.Count);
|
||||
DurableHaltRequestedEvent haltResult = Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||
Assert.Equal("executor-1", haltResult.ExecutorId);
|
||||
DurableWorkflowCompletedEvent completedResult = Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||
Assert.Equal("result", completedResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_CompletedWithoutWrapper_YieldsFailedEventAsync()
|
||||
{
|
||||
// Arrange — output not wrapped in DurableWorkflowResult (indicates a bug)
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: "\"raw output\""));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — yields a failed event with diagnostic message instead of crashing
|
||||
Assert.Single(events);
|
||||
DurableWorkflowFailedEvent failedEvent = Assert.IsType<DurableWorkflowFailedEvent>(events[0]);
|
||||
Assert.Contains("could not be parsed", failedEvent.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_Failed_YieldsFailedEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
TaskFailureDetails failureDetails = new("ErrorType", "Something went wrong", null, null, null);
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(
|
||||
OrchestrationRuntimeStatus.Failed,
|
||||
failureDetails: failureDetails));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(events);
|
||||
DurableWorkflowFailedEvent failedEvent = Assert.IsType<DurableWorkflowFailedEvent>(events[0]);
|
||||
Assert.Equal("Something went wrong", failedEvent.ErrorMessage);
|
||||
Assert.NotNull(failedEvent.FailureDetails);
|
||||
Assert.Equal("ErrorType", failedEvent.FailureDetails.ErrorType);
|
||||
Assert.Equal("Something went wrong", failedEvent.FailureDetails.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_FailedWithNoDetails_YieldsDefaultMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Failed));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(events);
|
||||
DurableWorkflowFailedEvent failedEvent = Assert.IsType<DurableWorkflowFailedEvent>(events[0]);
|
||||
Assert.Equal("Workflow execution failed.", failedEvent.ErrorMessage);
|
||||
Assert.Null(failedEvent.FailureDetails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_Terminated_YieldsFailedEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(events);
|
||||
DurableWorkflowFailedEvent failedEvent = Assert.IsType<DurableWorkflowFailedEvent>(events[0]);
|
||||
Assert.Equal("Workflow was terminated.", failedEvent.ErrorMessage);
|
||||
Assert.Null(failedEvent.FailureDetails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_EventsInCustomStatus_YieldsEventsBeforeCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableHaltRequestedEvent haltEvent = new("exec-1");
|
||||
string serializedEvent = SerializeEvent(haltEvent);
|
||||
string customStatus = SerializeCustomStatus([serializedEvent]);
|
||||
string serializedOutput = SerializeWorkflowResult("final", []);
|
||||
|
||||
int callCount = 0;
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus);
|
||||
}
|
||||
|
||||
return CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput);
|
||||
});
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, events.Count);
|
||||
DurableHaltRequestedEvent haltResult = Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||
Assert.Equal("exec-1", haltResult.ExecutorId);
|
||||
DurableWorkflowCompletedEvent completedResult = Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||
Assert.Equal("final", completedResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_EventTypeNameHasMutatedAssemblyVersion_StillDeserializesAsync()
|
||||
{
|
||||
// Arrange — model what happens after a package upgrade: the persisted TypedPayload.TypeName
|
||||
// carries an assembly Version= that no longer matches any loaded assembly.
|
||||
DurableHaltRequestedEvent haltEvent = new("exec-1");
|
||||
Type eventType = haltEvent.GetType();
|
||||
string outerSimpleName = eventType.Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"{eventType.FullName}, {outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
TypedPayload wrapper = new()
|
||||
{
|
||||
TypeName = mutatedTypeName,
|
||||
Data = JsonSerializer.Serialize(haltEvent, eventType, DurableSerialization.Options)
|
||||
};
|
||||
string serializedEvent = JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload);
|
||||
string customStatus = SerializeCustomStatus([serializedEvent]);
|
||||
string serializedOutput = SerializeWorkflowResult("final", []);
|
||||
|
||||
int callCount = 0;
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus);
|
||||
}
|
||||
|
||||
return CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput);
|
||||
});
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, events.Count);
|
||||
DurableHaltRequestedEvent haltResult = Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||
Assert.Equal("exec-1", haltResult.ExecutorId);
|
||||
DurableWorkflowCompletedEvent completedResult = Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||
Assert.Equal("final", completedResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_IncrementalEvents_YieldsOnlyNewEventsPerPollAsync()
|
||||
{
|
||||
// Arrange — simulate 3 poll cycles where events accumulate in custom status,
|
||||
// then a final completion poll. This validates:
|
||||
// 1. Events arriving across multiple poll cycles are yielded incrementally
|
||||
// 2. Already-seen events are not re-yielded (lastReadEventIndex dedup)
|
||||
// 3. Completion event follows all streamed events
|
||||
DurableHaltRequestedEvent event1 = new("executor-1");
|
||||
DurableHaltRequestedEvent event2 = new("executor-2");
|
||||
DurableHaltRequestedEvent event3 = new("executor-3");
|
||||
|
||||
string serializedEvent1 = SerializeEvent(event1);
|
||||
string serializedEvent2 = SerializeEvent(event2);
|
||||
string serializedEvent3 = SerializeEvent(event3);
|
||||
|
||||
// Poll 1: 1 event in custom status
|
||||
string customStatus1 = SerializeCustomStatus([serializedEvent1]);
|
||||
// Poll 2: same event + 1 new event (accumulating list)
|
||||
string customStatus2 = SerializeCustomStatus([serializedEvent1, serializedEvent2]);
|
||||
// Poll 3: all 3 events accumulated
|
||||
string customStatus3 = SerializeCustomStatus([serializedEvent1, serializedEvent2, serializedEvent3]);
|
||||
// Poll 4: completed, all events also in output
|
||||
string serializedOutput = SerializeWorkflowResult("done", [serializedEvent1, serializedEvent2, serializedEvent3]);
|
||||
|
||||
int callCount = 0;
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
return callCount switch
|
||||
{
|
||||
1 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus1),
|
||||
2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus2),
|
||||
3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus3),
|
||||
_ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput),
|
||||
};
|
||||
});
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — exactly 4 events: 3 incremental halt events + 1 completion
|
||||
Assert.Equal(4, events.Count);
|
||||
DurableHaltRequestedEvent halt1 = Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||
DurableHaltRequestedEvent halt2 = Assert.IsType<DurableHaltRequestedEvent>(events[1]);
|
||||
DurableHaltRequestedEvent halt3 = Assert.IsType<DurableHaltRequestedEvent>(events[2]);
|
||||
Assert.Equal("executor-1", halt1.ExecutorId);
|
||||
Assert.Equal("executor-2", halt2.ExecutorId);
|
||||
Assert.Equal("executor-3", halt3.ExecutorId);
|
||||
DurableWorkflowCompletedEvent completed = Assert.IsType<DurableWorkflowCompletedEvent>(events[3]);
|
||||
Assert.Equal("done", completed.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_NoNewEventsOnRepoll_DoesNotDuplicateAsync()
|
||||
{
|
||||
// Arrange — simulate polling where custom status doesn't change between polls,
|
||||
// validating that events are not duplicated when the list is unchanged.
|
||||
DurableHaltRequestedEvent event1 = new("executor-1");
|
||||
string serializedEvent1 = SerializeEvent(event1);
|
||||
string customStatus = SerializeCustomStatus([serializedEvent1]);
|
||||
string serializedOutput = SerializeWorkflowResult("result", [serializedEvent1]);
|
||||
|
||||
int callCount = 0;
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
return callCount switch
|
||||
{
|
||||
// First 3 polls return the same custom status (no new events after first)
|
||||
<= 3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus),
|
||||
_ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput),
|
||||
};
|
||||
});
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — event1 appears exactly once despite 3 polls with the same status
|
||||
Assert.Equal(2, events.Count);
|
||||
DurableHaltRequestedEvent haltResult = Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||
Assert.Equal("executor-1", haltResult.ExecutorId);
|
||||
DurableWorkflowCompletedEvent completedResult = Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||
Assert.Equal("result", completedResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_Cancellation_EndsGracefullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
using CancellationTokenSource cts = new();
|
||||
int pollCount = 0;
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
if (++pollCount >= 2)
|
||||
{
|
||||
cts.Cancel();
|
||||
}
|
||||
|
||||
return CreateMetadata(OrchestrationRuntimeStatus.Running);
|
||||
});
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — no exception thrown, stream ends cleanly
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_PendingRequestPort_YieldsWaitingForInputEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
string customStatus = SerializeCustomStatusWithPendingEvents(
|
||||
[],
|
||||
[new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]);
|
||||
string serializedOutput = SerializeWorkflowResult("approved", []);
|
||||
|
||||
int callCount = 0;
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
return callCount == 1
|
||||
? CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus)
|
||||
: CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput);
|
||||
});
|
||||
|
||||
Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort");
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, events.Count);
|
||||
DurableWorkflowWaitingForInputEvent waitingEvent = Assert.IsType<DurableWorkflowWaitingForInputEvent>(events[0]);
|
||||
Assert.Equal("ApprovalPort", waitingEvent.RequestPort.Id);
|
||||
Assert.Contains("amount", waitingEvent.Input);
|
||||
DurableWorkflowCompletedEvent completedEvent = Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||
Assert.Equal("approved", completedEvent.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchStreamAsync_PendingRequestPort_DoesNotDuplicateOnSubsequentPollsAsync()
|
||||
{
|
||||
// Arrange — same pending event across 2 polls, then completion
|
||||
string customStatus = SerializeCustomStatusWithPendingEvents(
|
||||
[],
|
||||
[new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]);
|
||||
string serializedOutput = SerializeWorkflowResult("done", []);
|
||||
|
||||
int callCount = 0;
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
return callCount switch
|
||||
{
|
||||
<= 2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus),
|
||||
_ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput),
|
||||
};
|
||||
});
|
||||
|
||||
Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort");
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
|
||||
|
||||
// Act
|
||||
List<WorkflowEvent> events = [];
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — WaitingForInputEvent yielded only once despite 2 polls
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.IsType<DurableWorkflowWaitingForInputEvent>(events[0]);
|
||||
Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendResponseAsync
|
||||
|
||||
[Fact]
|
||||
public async Task SendResponseAsync_SerializesAndRaisesEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.RaiseEventAsync(
|
||||
InstanceId,
|
||||
"ApprovalPort",
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
RequestPort approvalPort = RequestPort.Create<string, string>("ApprovalPort");
|
||||
DurableWorkflowWaitingForInputEvent requestEvent = new("""{"amount":100}""", approvalPort);
|
||||
Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort");
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
|
||||
|
||||
// Act
|
||||
await run.SendResponseAsync(requestEvent, new { approved = true, comments = "Looks good" });
|
||||
|
||||
// Assert
|
||||
mockClient.Verify(c => c.RaiseEventAsync(
|
||||
InstanceId,
|
||||
"ApprovalPort",
|
||||
It.Is<string>(s => s.Contains("approved") && s.Contains("true")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendResponseAsync_NullRequestEvent_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
run.SendResponseAsync(null!, "response").AsTask());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WaitForCompletionAsync
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForCompletionAsync_Completed_ReturnsResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
string serializedOutput = SerializeWorkflowResult("hello world", []);
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
string? result = await run.WaitForCompletionAsync<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello world", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForCompletionAsync_Failed_ThrowsTaskFailedExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(
|
||||
OrchestrationRuntimeStatus.Failed,
|
||||
failureDetails: new TaskFailureDetails("Error", "kaboom", null, null, null)));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act & Assert
|
||||
TaskFailedException ex = await Assert.ThrowsAsync<TaskFailedException>(
|
||||
() => run.WaitForCompletionAsync<string>().AsTask());
|
||||
Assert.Equal("kaboom", ex.FailureDetails.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForCompletionAsync_UnexpectedStatus_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<DurableTaskClient> mockClient = new("test");
|
||||
mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated));
|
||||
|
||||
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => run.WaitForCompletionAsync<string>().AsTask());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExtractResult
|
||||
|
||||
[Fact]
|
||||
public void ExtractResult_NullOutput_ReturnsDefault()
|
||||
{
|
||||
// Act
|
||||
string? result = DurableStreamingWorkflowRun.ExtractResult<string>(null);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResult_WrappedStringResult_ReturnsUnwrappedString()
|
||||
{
|
||||
// Arrange
|
||||
string serializedOutput = SerializeWorkflowResult("hello", []);
|
||||
|
||||
// Act
|
||||
string? result = DurableStreamingWorkflowRun.ExtractResult<string>(serializedOutput);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResult_UnwrappedOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange — raw output not wrapped in DurableWorkflowResult
|
||||
string serializedOutput = JsonSerializer.Serialize("raw value");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => DurableStreamingWorkflowRun.ExtractResult<string>(serializedOutput));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResult_WrappedObjectResult_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
TestPayload original = new() { Name = "test", Value = 42 };
|
||||
string resultJson = JsonSerializer.Serialize(original);
|
||||
string serializedOutput = SerializeWorkflowResult(resultJson, []);
|
||||
|
||||
// Act
|
||||
TestPayload? result = DurableStreamingWorkflowRun.ExtractResult<TestPayload>(serializedOutput);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("test", result.Name);
|
||||
Assert.Equal(42, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResult_CamelCaseSerializedObject_DeserializesToPascalCaseMembers()
|
||||
{
|
||||
// Arrange — executor outputs are serialized with DurableSerialization.Options (camelCase)
|
||||
TestPayload original = new() { Name = "camel", Value = 99 };
|
||||
string resultJson = JsonSerializer.Serialize(original, DurableSerialization.Options);
|
||||
string serializedOutput = SerializeWorkflowResult(resultJson, []);
|
||||
|
||||
// Act
|
||||
TestPayload? result = DurableStreamingWorkflowRun.ExtractResult<TestPayload>(serializedOutput);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("camel", result.Name);
|
||||
Assert.Equal(99, result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestPayload
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public int Value { get; set; }
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="DurableTaskTypeResolver.Resolve(string)"/> resolves persisted
|
||||
/// assembly-qualified type-name strings to a loaded <see cref="Type"/> across assembly
|
||||
/// version, culture, and public key token mutations.
|
||||
/// </summary>
|
||||
public sealed class DurableTaskTypeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_LoadedAssemblyQualifiedName_ReturnsLiveType()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
string aqn = live.AssemblyQualifiedName!;
|
||||
|
||||
Type? resolved = DurableTaskTypeResolver.Resolve(aqn);
|
||||
|
||||
Assert.Same(live, resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MutatedOuterAssemblyVersion_ReturnsLiveType()
|
||||
{
|
||||
Type live = typeof(ChatMessage);
|
||||
string outerSimpleName = live.Assembly.GetName().Name!;
|
||||
string mutated = $"{live.FullName}, {outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
|
||||
Type? resolved = DurableTaskTypeResolver.Resolve(mutated);
|
||||
|
||||
Assert.Same(live, resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MutatedGenericArgumentVersion_ReturnsLiveType()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
string outerSimpleName = live.Assembly.GetName().Name!;
|
||||
string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutated =
|
||||
$"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, " +
|
||||
"Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]], " +
|
||||
$"{outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
|
||||
Type? resolved = DurableTaskTypeResolver.Resolve(mutated);
|
||||
|
||||
Assert.Same(live, resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnknownType_ReturnsNull()
|
||||
{
|
||||
Type? resolved = DurableTaskTypeResolver.Resolve(
|
||||
"Some.Unknown.Namespace.MissingType, Some.Unloaded.Assembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
|
||||
|
||||
Assert.Null(resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CachesResults()
|
||||
{
|
||||
Type live = typeof(ChatMessage);
|
||||
string aqn = live.AssemblyQualifiedName!;
|
||||
|
||||
Type? first = DurableTaskTypeResolver.Resolve(aqn);
|
||||
Type? second = DurableTaskTypeResolver.Resolve(aqn);
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Same(live, second);
|
||||
}
|
||||
}
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||
|
||||
public sealed class DurableWorkflowContextTests
|
||||
{
|
||||
private static FunctionExecutor<string> CreateTestExecutor(string id = "test-executor")
|
||||
=> new(id, (_, _, _) => default, outputTypes: [typeof(string)]);
|
||||
|
||||
#region ReadStateAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateAsync_KeyExistsInInitialState_ReturnsValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new() { ["__default__:counter"] = "42" };
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
int? result = await context.ReadStateAsync<int>("counter");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateAsync_KeyDoesNotExist_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
string? result = await context.ReadStateAsync<string>("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateAsync_LocalUpdateTakesPriorityOverInitialStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new() { ["__default__:key"] = "\"old\"" };
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
await context.QueueStateUpdateAsync("key", "new");
|
||||
|
||||
// Act
|
||||
string? result = await context.ReadStateAsync<string>("key");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("new", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateAsync_ScopeCleared_IgnoresInitialStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new() { ["__default__:key"] = "\"value\"" };
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
await context.QueueClearScopeAsync();
|
||||
|
||||
// Act
|
||||
string? result = await context.ReadStateAsync<string>("key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateAsync_WithNamedScope_ReadsFromCorrectScopeAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new()
|
||||
{
|
||||
["scopeA:key"] = "\"fromA\"",
|
||||
["scopeB:key"] = "\"fromB\""
|
||||
};
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
string? resultA = await context.ReadStateAsync<string>("key", "scopeA");
|
||||
string? resultB = await context.ReadStateAsync<string>("key", "scopeB");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("fromA", resultA);
|
||||
Assert.Equal("fromB", resultB);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task ReadStateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key)
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => context.ReadStateAsync<string>(key!).AsTask());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReadOrInitStateAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOrInitStateAsync_KeyDoesNotExist_CallsFactoryAndQueuesUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
string result = await context.ReadOrInitStateAsync("key", () => "initialized");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("initialized", result);
|
||||
Assert.True(context.StateUpdates.ContainsKey("__default__:key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOrInitStateAsync_KeyExists_ReturnsExistingValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new() { ["__default__:key"] = "\"existing\"" };
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
bool factoryCalled = false;
|
||||
|
||||
// Act
|
||||
string result = await context.ReadOrInitStateAsync("key", () =>
|
||||
{
|
||||
factoryCalled = true;
|
||||
return "should-not-be-used";
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("existing", result);
|
||||
Assert.False(factoryCalled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task ReadOrInitStateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key)
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => context.ReadOrInitStateAsync(key!, () => "value").AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOrInitStateAsync_ValueType_MissingKey_CallsFactoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
// Validates that ReadStateAsync<int> returns null (not 0) for missing keys,
|
||||
// because the return type is int? (Nullable<int>). This ensures the factory
|
||||
// is correctly invoked for value types when the key does not exist.
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
int result = await context.ReadOrInitStateAsync("counter", () => 42);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(42, result);
|
||||
Assert.True(context.StateUpdates.ContainsKey("__default__:counter"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOrInitStateAsync_NullFactory_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => context.ReadOrInitStateAsync<string>("key", null!).AsTask());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region QueueStateUpdateAsync
|
||||
|
||||
[Fact]
|
||||
public async Task QueueStateUpdateAsync_SetsValue_VisibleToSubsequentReadAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
await context.QueueStateUpdateAsync("key", "hello");
|
||||
string? result = await context.ReadStateAsync<string>("key");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueStateUpdateAsync_NullValue_RecordsDeletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new() { ["__default__:key"] = "\"value\"" };
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
await context.QueueStateUpdateAsync<string>("key", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.StateUpdates.ContainsKey("__default__:key"));
|
||||
Assert.Null(context.StateUpdates["__default__:key"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task QueueStateUpdateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key)
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => context.QueueStateUpdateAsync(key!, "value").AsTask());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region QueueClearScopeAsync
|
||||
|
||||
[Fact]
|
||||
public async Task QueueClearScopeAsync_DefaultScope_ClearsStateAndPendingUpdatesAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new() { ["__default__:key"] = "\"value\"" };
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
await context.QueueStateUpdateAsync("pending", "data");
|
||||
|
||||
// Act
|
||||
await context.QueueClearScopeAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("__default__", context.ClearedScopes);
|
||||
Assert.Empty(context.StateUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueClearScopeAsync_NamedScope_OnlyClearsThatScopeAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
await context.QueueStateUpdateAsync("keyA", "valueA", scopeName: "scopeA");
|
||||
await context.QueueStateUpdateAsync("keyB", "valueB", scopeName: "scopeB");
|
||||
|
||||
// Act
|
||||
await context.QueueClearScopeAsync("scopeA");
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("scopeA:keyA", context.StateUpdates.Keys);
|
||||
Assert.Contains("scopeB:keyB", context.StateUpdates.Keys);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReadStateKeysAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateKeysAsync_ReturnsKeysFromInitialStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new()
|
||||
{
|
||||
["__default__:alpha"] = "\"a\"",
|
||||
["__default__:beta"] = "\"b\""
|
||||
};
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
HashSet<string> keys = await context.ReadStateKeysAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Contains("alpha", keys);
|
||||
Assert.Contains("beta", keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateKeysAsync_MergesLocalUpdatesAndDeletionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new()
|
||||
{
|
||||
["__default__:existing"] = "\"val\"",
|
||||
["__default__:toDelete"] = "\"val\""
|
||||
};
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
await context.QueueStateUpdateAsync("newKey", "value");
|
||||
await context.QueueStateUpdateAsync<string>("toDelete", null);
|
||||
|
||||
// Act
|
||||
HashSet<string> keys = await context.ReadStateKeysAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("existing", keys);
|
||||
Assert.Contains("newKey", keys);
|
||||
Assert.DoesNotContain("toDelete", keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateKeysAsync_AfterClearScope_ExcludesInitialStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new() { ["__default__:old"] = "\"val\"" };
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
await context.QueueClearScopeAsync();
|
||||
await context.QueueStateUpdateAsync("new", "value");
|
||||
|
||||
// Act
|
||||
HashSet<string> keys = await context.ReadStateKeysAsync();
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("old", keys);
|
||||
Assert.Contains("new", keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStateKeysAsync_WithNamedScope_OnlyReturnsKeysFromThatScopeAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> state = new()
|
||||
{
|
||||
["scopeA:key1"] = "\"val\"",
|
||||
["scopeB:key2"] = "\"val\""
|
||||
};
|
||||
DurableWorkflowContext context = new(state, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
HashSet<string> keysA = await context.ReadStateKeysAsync("scopeA");
|
||||
|
||||
// Assert
|
||||
Assert.Single(keysA);
|
||||
Assert.Contains("key1", keysA);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AddEventAsync
|
||||
|
||||
[Fact]
|
||||
public async Task AddEventAsync_AddsEventToCollectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
WorkflowEvent evt = new ExecutorInvokedEvent("test", "test-data");
|
||||
|
||||
// Act
|
||||
await context.AddEventAsync(evt);
|
||||
|
||||
// Assert
|
||||
Assert.Single(context.OutboundEvents);
|
||||
Assert.Same(evt, context.OutboundEvents[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddEventAsync_NullEvent_DoesNotAddAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||
await context.AddEventAsync(null);
|
||||
#pragma warning restore CS8625
|
||||
|
||||
// Assert
|
||||
Assert.Empty(context.OutboundEvents);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendMessageAsync
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_SerializesMessageWithTypeNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
await context.SendMessageAsync("hello");
|
||||
|
||||
// Assert
|
||||
Assert.Single(context.SentMessages);
|
||||
Assert.Equal(typeof(string).AssemblyQualifiedName, context.SentMessages[0].TypeName);
|
||||
Assert.NotNull(context.SentMessages[0].Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_NullMessage_DoesNotAddAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||
await context.SendMessageAsync(null);
|
||||
#pragma warning restore CS8625
|
||||
|
||||
// Assert
|
||||
Assert.Empty(context.SentMessages);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region YieldOutputAsync
|
||||
|
||||
[Fact]
|
||||
public async Task YieldOutputAsync_AddsWorkflowOutputEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
await context.YieldOutputAsync("result");
|
||||
|
||||
// Assert
|
||||
Assert.Single(context.OutboundEvents);
|
||||
WorkflowOutputEvent outputEvent = Assert.IsType<WorkflowOutputEvent>(context.OutboundEvents[0]);
|
||||
Assert.Equal("result", outputEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task YieldOutputAsync_NullOutput_DoesNotAddAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||
await context.YieldOutputAsync(null);
|
||||
#pragma warning restore CS8625
|
||||
|
||||
// Assert
|
||||
Assert.Empty(context.OutboundEvents);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RequestHaltAsync
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHaltAsync_SetsHaltRequestedAndAddsEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Act
|
||||
await context.RequestHaltAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(context.HaltRequested);
|
||||
Assert.Single(context.OutboundEvents);
|
||||
Assert.IsType<DurableHaltRequestedEvent>(context.OutboundEvents[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
[Fact]
|
||||
public void TraceContext_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Assert
|
||||
Assert.Null(context.TraceContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentRunsEnabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Assert
|
||||
Assert.False(context.ConcurrentRunsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_NullInitialState_CreatesEmptyStateAsync()
|
||||
{
|
||||
// Arrange & Act
|
||||
DurableWorkflowContext context = new(null, CreateTestExecutor());
|
||||
|
||||
// Assert
|
||||
string? result = await context.ReadStateAsync<string>("anything");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||
|
||||
public sealed class WorkflowNamingHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToOrchestrationFunctionName_ValidWorkflowName_ReturnsPrefixedName()
|
||||
{
|
||||
string result = WorkflowNamingHelper.ToOrchestrationFunctionName("MyWorkflow");
|
||||
|
||||
Assert.Equal("dafx-MyWorkflow", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void ToOrchestrationFunctionName_NullOrEmpty_ThrowsArgumentException(string? workflowName)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWorkflowName_ValidOrchestrationFunctionName_ReturnsWorkflowName()
|
||||
{
|
||||
string result = WorkflowNamingHelper.ToWorkflowName("dafx-MyWorkflow");
|
||||
|
||||
Assert.Equal("MyWorkflow", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void ToWorkflowName_NullOrEmpty_ThrowsArgumentException(string? orchestrationFunctionName)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MyWorkflow")]
|
||||
[InlineData("invalid-prefix-MyWorkflow")]
|
||||
[InlineData("dafx")]
|
||||
[InlineData("dafx-")]
|
||||
public void ToWorkflowName_InvalidOrMissingPrefix_ThrowsArgumentException(string orchestrationFunctionName)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExecutorName_SimpleExecutorId_ReturnsSameName()
|
||||
{
|
||||
string result = WorkflowNamingHelper.GetExecutorName("OrderParser");
|
||||
|
||||
Assert.Equal("OrderParser", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExecutorName_ExecutorIdWithGuidSuffix_ReturnsNameWithoutSuffix()
|
||||
{
|
||||
string result = WorkflowNamingHelper.GetExecutorName("Physicist_8884e71021334ce49517fa2b17b1695b");
|
||||
|
||||
Assert.Equal("Physicist", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExecutorName_NameWithUnderscoresAndGuidSuffix_ReturnsFullName()
|
||||
{
|
||||
string result = WorkflowNamingHelper.GetExecutorName("my_agent_8884e71021334ce49517fa2b17b1695b");
|
||||
|
||||
Assert.Equal("my_agent", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExecutorName_NameWithUnderscoreButNoGuidSuffix_ReturnsSameName()
|
||||
{
|
||||
string result = WorkflowNamingHelper.GetExecutorName("my_custom_executor");
|
||||
|
||||
Assert.Equal("my_custom_executor", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void GetExecutorName_NullOrEmpty_ThrowsArgumentException(string? executorId)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.GetExecutorName(executorId!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user