Files
westey 3168eb4870 .NET: [BREAKING] Add session StateBag for state storage and support multiple providers on the Agent (#3806)
* .NET: [BREAKING] Add session statebag to use for state storage instead of inside providers (#3737)

* Add a StateBag to AgentSession and pass Agent and AgentSession to AIContextProvider and ChatHistoryProviders

* Convert all AIContextProviders to use the statebag

* Update InMemoryChatHistoryProvider to use StateBag

* Update Comsos and Workflow ChatHistoryProviders

* Update 3rd party chat history storage sample.

* Remove serialize method from providers

* Replacing provider factories with properties

* Remove Providers from Session and flatten state bag serialization

* Update samples to use getservice on agent

* Updated additional session types to serialize statebag

* Fix regression

* Address PR comments

* Address PR comments.

* Fix formatting

* Fix unit tests

* Remove InMemoryAgentSession since it is not required anymore.

* Address PR comments

* Convert sessions for A2AAgent, ChatClientAgent, CopilotStudioAgent and GithubCopilotAgent to use regular json serialization.

* Fix durable agent session jso usgae

* Add jso to InMemory and Workflow ChatHistoryProviders

* Update InMemoryChatHistoryProvider to use an options class for it's many optional settings.

* Apply suggestions from code review

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

* Address PR feedback

* Fix verification bug.

* Improve state bag thread safety

* Address PR comments and fix unit tests

* Address PR comments

* Fix unit test

---------

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

* Add a public StateKey property to providers (#3810)

* .NET: [BREAKING] Update providers in such a way that they can participate in a pipeline (#3846)

* Make providers pipeline capable

* Fix unit tests

* Move source stamping to providers from base class

* Also update samples.

* Address PR comments

* Rename AsAgentRequestMessageSourcedMessage to WithAgentRequestMessageSource

* .NET: [BREAKING] Add consistent message filtering to all providers. (#3851)

* Add consistent message filtering to all providers.

* Remove old chat history filtering classes

* Fix merge issues

* Fix unit test

* Enforce non-nullable property

* Fix merging bug and make troubleshooting source info easier by adding tostring implementation

* .NET: [BREAKING] Add support for multiple AIContextProviders on a ChatClientAgent (#3863)

* Add support for multiple AIContextProviders on a ChatClientAgent

* Address PR comments and fix tests

* Address PR comments.

* .NET: [BREAKING]Delay AIContext Materialization until the end of the pipeline is reached. (#3883)

* Delay AIContext Materialization until the end of the pipeline is reached.

* Address PR comments.

* Address PR comments

* Modify InvokedContext to be immutable (#3888)

* .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>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 14:08:07 +00:00

368 lines
14 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;
public sealed class ForwardedPropertiesTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _client;
[Fact]
public async Task ForwardedProps_AreParsedAndPassedToAgent_WhenProvidedInRequestAsync()
{
// Arrange
FakeForwardedPropsAgent fakeAgent = new();
await this.SetupTestServerAsync(fakeAgent);
// Create request JSON with forwardedProps (per AG-UI protocol spec)
const string RequestJson = """
{
"threadId": "session-123",
"runId": "run-456",
"messages": [{ "id": "msg-1", "role": "user", "content": "test forwarded props" }],
"forwardedProps": { "customProp": "customValue", "sessionId": "test-session-123" }
}
""";
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
// Act
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
fakeAgent.ReceivedForwardedProperties.GetProperty("customProp").GetString().Should().Be("customValue");
fakeAgent.ReceivedForwardedProperties.GetProperty("sessionId").GetString().Should().Be("test-session-123");
}
[Fact]
public async Task ForwardedProps_WithNestedObjects_AreCorrectlyParsedAsync()
{
// Arrange
FakeForwardedPropsAgent fakeAgent = new();
await this.SetupTestServerAsync(fakeAgent);
const string RequestJson = """
{
"threadId": "session-123",
"runId": "run-456",
"messages": [{ "id": "msg-1", "role": "user", "content": "test nested props" }],
"forwardedProps": {
"user": { "id": "user-1", "name": "Test User" },
"metadata": { "version": "1.0", "feature": "test" }
}
}
""";
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
// Act
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
JsonElement user = fakeAgent.ReceivedForwardedProperties.GetProperty("user");
user.GetProperty("id").GetString().Should().Be("user-1");
user.GetProperty("name").GetString().Should().Be("Test User");
JsonElement metadata = fakeAgent.ReceivedForwardedProperties.GetProperty("metadata");
metadata.GetProperty("version").GetString().Should().Be("1.0");
metadata.GetProperty("feature").GetString().Should().Be("test");
}
[Fact]
public async Task ForwardedProps_WithArrays_AreCorrectlyParsedAsync()
{
// Arrange
FakeForwardedPropsAgent fakeAgent = new();
await this.SetupTestServerAsync(fakeAgent);
const string RequestJson = """
{
"threadId": "session-123",
"runId": "run-456",
"messages": [{ "id": "msg-1", "role": "user", "content": "test array props" }],
"forwardedProps": {
"tags": ["tag1", "tag2", "tag3"],
"scores": [1, 2, 3, 4, 5]
}
}
""";
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
// Act
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
JsonElement tags = fakeAgent.ReceivedForwardedProperties.GetProperty("tags");
tags.GetArrayLength().Should().Be(3);
tags[0].GetString().Should().Be("tag1");
JsonElement scores = fakeAgent.ReceivedForwardedProperties.GetProperty("scores");
scores.GetArrayLength().Should().Be(5);
scores[2].GetInt32().Should().Be(3);
}
[Fact]
public async Task ForwardedProps_WhenEmpty_DoesNotCauseErrorsAsync()
{
// Arrange
FakeForwardedPropsAgent fakeAgent = new();
await this.SetupTestServerAsync(fakeAgent);
const string RequestJson = """
{
"threadId": "session-123",
"runId": "run-456",
"messages": [{ "id": "msg-1", "role": "user", "content": "test empty props" }],
"forwardedProps": {}
}
""";
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
// Act
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
}
[Fact]
public async Task ForwardedProps_WhenNotProvided_AgentStillWorksAsync()
{
// Arrange
FakeForwardedPropsAgent fakeAgent = new();
await this.SetupTestServerAsync(fakeAgent);
const string RequestJson = """
{
"threadId": "session-123",
"runId": "run-456",
"messages": [{ "id": "msg-1", "role": "user", "content": "test no props" }]
}
""";
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
// Act
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Undefined);
}
[Fact]
public async Task ForwardedProps_ReturnsValidSSEResponse_WithTextDeltaEventsAsync()
{
// Arrange
FakeForwardedPropsAgent fakeAgent = new();
await this.SetupTestServerAsync(fakeAgent);
const string RequestJson = """
{
"threadId": "session-123",
"runId": "run-456",
"messages": [{ "id": "msg-1", "role": "user", "content": "test response" }],
"forwardedProps": { "customProp": "value" }
}
""";
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
// Act
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
response.EnsureSuccessStatusCode();
Stream stream = await response.Content.ReadAsStreamAsync();
List<SseItem<string>> events = [];
await foreach (SseItem<string> item in SseParser.Create(stream).EnumerateAsync())
{
events.Add(item);
}
// Assert
events.Should().NotBeEmpty();
// SSE events have EventType = "message" and the actual type is in the JSON data
// Should have run_started event
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"RUN_STARTED\""));
// Should have text_message_start event
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"TEXT_MESSAGE_START\""));
// Should have text_message_content event with the response text
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"TEXT_MESSAGE_CONTENT\""));
// Should have run_finished event
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"RUN_FINISHED\""));
}
[Fact]
public async Task ForwardedProps_WithMixedTypes_AreCorrectlyParsedAsync()
{
// Arrange
FakeForwardedPropsAgent fakeAgent = new();
await this.SetupTestServerAsync(fakeAgent);
const string RequestJson = """
{
"threadId": "session-123",
"runId": "run-456",
"messages": [{ "id": "msg-1", "role": "user", "content": "test mixed types" }],
"forwardedProps": {
"stringProp": "text",
"numberProp": 42,
"boolProp": true,
"nullProp": null,
"arrayProp": [1, "two", false],
"objectProp": { "nested": "value" }
}
}
""";
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
// Act
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
fakeAgent.ReceivedForwardedProperties.GetProperty("stringProp").GetString().Should().Be("text");
fakeAgent.ReceivedForwardedProperties.GetProperty("numberProp").GetInt32().Should().Be(42);
fakeAgent.ReceivedForwardedProperties.GetProperty("boolProp").GetBoolean().Should().BeTrue();
fakeAgent.ReceivedForwardedProperties.GetProperty("nullProp").ValueKind.Should().Be(JsonValueKind.Null);
fakeAgent.ReceivedForwardedProperties.GetProperty("arrayProp").GetArrayLength().Should().Be(3);
fakeAgent.ReceivedForwardedProperties.GetProperty("objectProp").GetProperty("nested").GetString().Should().Be("value");
}
private async Task SetupTestServerAsync(FakeForwardedPropsAgent fakeAgent)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddAGUI();
builder.WebHost.UseTestServer();
this._app = builder.Build();
this._app.MapAGUI("/agent", fakeAgent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._client = testServer.CreateClient();
}
public async ValueTask DisposeAsync()
{
this._client?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated in tests")]
internal sealed class FakeForwardedPropsAgent : AIAgent
{
public FakeForwardedPropsAgent()
{
}
public override string? Description => "Agent for forwarded properties testing";
public JsonElement ReceivedForwardedProperties { get; private set; }
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Extract forwarded properties from ChatOptions.AdditionalProperties (set by AG-UI hosting layer)
if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } &&
properties.TryGetValue("ag_ui_forwarded_properties", out object? propsObj) &&
propsObj is JsonElement forwardedProps)
{
this.ReceivedForwardedProperties = forwardedProps;
}
// Always return a text response
string messageId = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate
{
MessageId = messageId,
Role = ChatRole.Assistant,
Contents = [new TextContent("Forwarded props processed")]
};
await Task.CompletedTask;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new FakeAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
new(serializedState.Deserialize<FakeAgentSession>(jsonSerializerOptions)!);
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
if (session is not FakeAgentSession fakeSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
}
return new(JsonSerializer.SerializeToElement(fakeSession, jsonSerializerOptions));
}
private sealed class FakeAgentSession : AgentSession
{
public FakeAgentSession()
{
}
[JsonConstructor]
public FakeAgentSession(AgentSessionStateBag stateBag) : base(stateBag)
{
}
}
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
}