diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs index a9396ff722..f64177146f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs @@ -32,7 +32,7 @@ internal sealed class RunAgentInput [JsonPropertyName("context")] public AGUIContextItem[] Context { get; set; } = []; - [JsonPropertyName("forwardedProperties")] + [JsonPropertyName("forwardedProps")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public JsonElement ForwardedProperties { get; set; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs new file mode 100644 index 0000000000..df8caea214 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs @@ -0,0 +1,358 @@ +// 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.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": "thread-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": "thread-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": "thread-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": "thread-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": "thread-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": "thread-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> events = []; + await foreach (SseItem 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": "thread-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() 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; } + + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = 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 AgentRunResponseUpdate + { + MessageId = messageId, + Role = ChatRole.Assistant, + Contents = [new TextContent("Forwarded props processed")] + }; + + await Task.CompletedTask; + } + + public override AgentThread GetNewThread() => new FakeInMemoryAgentThread(); + + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions); + } + + private sealed class FakeInMemoryAgentThread : InMemoryAgentThread + { + public FakeInMemoryAgentThread() + : base() + { + } + + public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + : base(serializedThread, jsonSerializerOptions) + { + } + } + + public override object? GetService(Type serviceType, object? serviceKey = null) => null; +}