.NET: Address Feedback on StateBag feature branch PR (#3910)

* Address Feedback on statebag feature branch PR

* Update dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Address PR comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-02-13 11:01:06 +00:00
committed by GitHub
Unverified
parent af801e57f8
commit 42b4328ac7
6 changed files with 142 additions and 30 deletions
@@ -59,13 +59,13 @@ namespace SampleApp
// Get existing messages from the store
var invokingContext = new ChatHistoryProvider.InvokingContext(this, session, messages);
var storeMessages = await this.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
var userAndChatHistoryMessages = await this.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
// Clone the input messages and turn them into response messages with upper case text.
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
// Notify the session of the input and output messages.
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, messages, responseMessages);
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, userAndChatHistoryMessages, responseMessages);
await this.ChatHistoryProvider.InvokedAsync(invokedContext, cancellationToken);
return new AgentResponse
@@ -88,13 +88,13 @@ namespace SampleApp
// Get existing messages from the store
var invokingContext = new ChatHistoryProvider.InvokingContext(this, session, messages);
var storeMessages = await this.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
var userAndChatHistoryMessages = await this.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
// Clone the input messages and turn them into response messages with upper case text.
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
// Notify the session of the input and output messages.
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, messages, responseMessages);
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, userAndChatHistoryMessages, responseMessages);
await this.ChatHistoryProvider.InvokedAsync(invokedContext, cancellationToken);
foreach (var message in responseMessages)
@@ -223,9 +223,9 @@ public abstract class AIContextProvider
/// Contains the context information provided to <see cref="InvokedCoreAsync(InvokedContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about a completed agent invocation, including both the
/// request messages that were used and the response messages that were generated. It also indicates
/// whether the invocation succeeded or failed.
/// This class provides context about a completed agent invocation, including the accumulated
/// request messages (user input, chat history and any others provided by AI context providers) that were used
/// and the response messages that were generated. It also indicates whether the invocation succeeded or failed.
/// </remarks>
public sealed class InvokedContext
{
@@ -234,7 +234,8 @@ public abstract class AIContextProvider
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The messages that were used by the agent for this invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="responseMessages">The response messages generated during this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="responseMessages"/> is <see langword="null"/>.</exception>
public InvokedContext(
@@ -254,7 +255,8 @@ public abstract class AIContextProvider
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The messages that were used by the agent for this invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="invokeException">The exception that caused the invocation to fail.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="invokeException"/> is <see langword="null"/>.</exception>
public InvokedContext(
@@ -280,7 +282,8 @@ public abstract class AIContextProvider
public AgentSession? Session { get; }
/// <summary>
/// Gets the messages that were used by the agent for this invocation.
/// Gets the accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing all messages that were used by the agent for this invocation.
@@ -78,10 +78,17 @@ internal class AgentSessionStateBagValue
lock (this._lock)
{
if (this._cache is { } cache)
switch (this._cache)
{
value = cache.Value as T;
return true;
case DeserializedCache { Value: null, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
value = null;
return true;
case DeserializedCache { Value: T cacheValue, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
value = cacheValue;
return true;
case DeserializedCache { ValueType: Type cacheValueType } when cacheValueType != typeof(T):
value = null;
return false;
}
switch (this._jsonValue)
@@ -118,9 +125,14 @@ internal class AgentSessionStateBagValue
lock (this._lock)
{
if (this._cache is { } cache)
switch (this._cache)
{
return cache.Value as T;
case DeserializedCache { Value: null, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
return null;
case DeserializedCache { Value: T cacheValue, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
return cacheValue;
case DeserializedCache { ValueType: Type cacheValueType } when cacheValueType != typeof(T):
throw new InvalidOperationException($"The type of the cached value is {cacheValueType.FullName}, but the requested type is {typeof(T).FullName}.");
}
switch (this._jsonValue)
@@ -144,7 +156,7 @@ internal class AgentSessionStateBagValue
/// Sets the deserialized value of this session state value, updating the cache accordingly.
/// This does not update the JsonValue directly; the JsonValue will be updated on the next read or when the object is serialized.
/// </summary>
public void SetDeserialized(object? deserializedValue, Type valueType, JsonSerializerOptions jsonSerializerOptions)
public void SetDeserialized<T>(T? deserializedValue, Type valueType, JsonSerializerOptions jsonSerializerOptions)
{
lock (this._lock)
{
@@ -253,9 +253,9 @@ public abstract class ChatHistoryProvider
/// Contains the context information provided to <see cref="InvokedCoreAsync(InvokedContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about a completed agent invocation, including both the
/// request messages that were used and the response messages that were generated. It also indicates
/// whether the invocation succeeded or failed.
/// This class provides context about a completed agent invocation, including the accumulated
/// request messages (user input, chat history and any others provided by AI context providers) that were used
/// and the response messages that were generated. It also indicates whether the invocation succeeded or failed.
/// </remarks>
public sealed class InvokedContext
{
@@ -264,7 +264,8 @@ public abstract class ChatHistoryProvider
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="responseMessages">The response messages generated during this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="responseMessages"/> is <see langword="null"/>.</exception>
public InvokedContext(
@@ -284,7 +285,8 @@ public abstract class ChatHistoryProvider
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="invokeException">The exception that caused the invocation to fail.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="invokeException"/> is <see langword="null"/>.</exception>
public InvokedContext(
@@ -310,7 +312,8 @@ public abstract class ChatHistoryProvider
public AgentSession? Session { get; }
/// <summary>
/// Gets the caller provided messages that were used by the agent for this invocation.
/// Gets the accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
@@ -12,6 +12,7 @@
- Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681))
- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
- Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
## v1.0.0-preview.251204.1
@@ -534,14 +534,9 @@ public sealed class AgentSessionStateBagTests
for (int i = 0; i < 200; i++)
{
int index = i;
if (index % 2 == 0)
{
tasks[i] = System.Threading.Tasks.Task.Run(() => stateBag.GetValue<string>("key"));
}
else
{
tasks[i] = System.Threading.Tasks.Task.Run(() => stateBag.SetValue("key", $"value{index}"));
}
tasks[i] = (index % 2 == 0)
? System.Threading.Tasks.Task.Run(() => stateBag.GetValue<string>("key"))
: System.Threading.Tasks.Task.Run(() => stateBag.SetValue("key", $"value{index}"));
}
await System.Threading.Tasks.Task.WhenAll(tasks);
@@ -650,6 +645,104 @@ public sealed class AgentSessionStateBagTests
#endregion
#region Type Mismatch Tests
[Fact]
public void TryGetValue_WithDifferentTypeAfterSet_ReturnsFalse()
{
// Arrange
var stateBag = new AgentSessionStateBag();
stateBag.SetValue("key1", "hello");
// Act
var found = stateBag.TryGetValue<Animal>("key1", out var result, TestJsonSerializerContext.Default.Options);
// Assert
Assert.False(found);
Assert.Null(result);
}
[Fact]
public void GetValue_WithDifferentTypeAfterSet_ThrowsInvalidOperationException()
{
// Arrange
var stateBag = new AgentSessionStateBag();
stateBag.SetValue("key1", "hello");
// Act & Assert
Assert.Throws<InvalidOperationException>(() => stateBag.GetValue<Animal>("key1", TestJsonSerializerContext.Default.Options));
}
[Fact]
public void TryGetValue_WithDifferentTypeAfterDeserializedRead_ReturnsFalse()
{
// Arrange
var stateBag = new AgentSessionStateBag();
stateBag.SetValue("key1", "hello");
// First read caches the value as string
var cachedValue = stateBag.GetValue<string>("key1");
Assert.Equal("hello", cachedValue);
// Act - request as a different type
var found = stateBag.TryGetValue<Animal>("key1", out var result, TestJsonSerializerContext.Default.Options);
// Assert
Assert.False(found);
Assert.Null(result);
}
[Fact]
public void GetValue_WithDifferentTypeAfterDeserializedRoundtrip_ThrowsInvalidOperationException()
{
// Arrange
var originalStateBag = new AgentSessionStateBag();
originalStateBag.SetValue("key1", "hello");
// Round-trip through serialization
var json = originalStateBag.Serialize();
var restoredStateBag = AgentSessionStateBag.Deserialize(json);
// First read caches the value as string
var cachedValue = restoredStateBag.GetValue<string>("key1");
Assert.Equal("hello", cachedValue);
// Act & Assert - request as a different type
Assert.Throws<InvalidOperationException>(() => restoredStateBag.GetValue<Animal>("key1", TestJsonSerializerContext.Default.Options));
}
[Fact]
public void TryGetValue_ComplexTypeAfterSetString_ReturnsFalse()
{
// Arrange
var stateBag = new AgentSessionStateBag();
stateBag.SetValue("animal", "not an animal");
// Act
var found = stateBag.TryGetValue<Animal>("animal", out var result, TestJsonSerializerContext.Default.Options);
// Assert
Assert.False(found);
Assert.Null(result);
}
[Fact]
public void GetValue_TypeMismatch_ExceptionMessageContainsBothTypeNames()
{
// Arrange
var stateBag = new AgentSessionStateBag();
stateBag.SetValue("key1", "hello");
// Act
var exception = Assert.Throws<InvalidOperationException>(() => stateBag.GetValue<Animal>("key1", TestJsonSerializerContext.Default.Options));
// Assert
Assert.Contains(typeof(string).FullName!, exception.Message);
Assert.Contains(typeof(Animal).FullName!, exception.Message);
}
#endregion
#region JsonSerializer Integration Tests
[Fact]