From d29e9af5c01c98ec296fd32e5bfdc151c97f3af7 Mon Sep 17 00:00:00 2001 From: darthmolen <533340+darthmolen@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:59:43 -0500 Subject: [PATCH] .NET: Add MapAGUIServer overload that resolves the agent per request via a factory Adds a per-request factory-delegate overload of MapAGUIServer for agents that must be built with request-scoped state (per-request auth, scoped tool/MCP sessions, conversation-scoped config) rather than a single instance captured at startup. Preserves stable session identity: AgentSessionStore keys persisted sessions by (agent.Id, conversationId), and AIAgent.Id defaults to a per-instance GUID. A per-request factory would therefore compute a different session key every turn and never find the previously saved session -- silently resetting persistence. The overload wraps the per-request agent in a StableIdentityAIAgent whose Id is the logical agentName, so the session key stays constant across requests, matching the startup-capture overloads. Both overloads now share BuildHostAgent (isolation-store wiring) and HandleRunAsync. Tests: factory mapping/deferral/null-arg unit tests; a stable-id assertion; a two-turn persistence round-trip through the real InMemoryAgentSessionStore and a negative control proving raw per-request instances lose the session; plus integration tests asserting per-request invocation and the null-factory failure. --- .../AGUIEndpointRouteBuilderExtensions.cs | 159 ++++++++++++----- .../StableIdentityAIAgent.cs | 36 ++++ .../MapAGUIFactoryDelegateTests.cs | 115 ++++++++++++ ...AGUIEndpointRouteBuilderExtensionsTests.cs | 164 ++++++++++++++++++ 4 files changed, 433 insertions(+), 41 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/StableIdentityAIAgent.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/MapAGUIFactoryDelegateTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index ea1f6196bfb..5df487df4a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -110,66 +110,143 @@ public static IEndpointConventionBuilder MapAGUIServer( ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(aiAgent); - var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(aiAgent.Name); + var hostAgent = BuildHostAgent(endpoints.ServiceProvider, aiAgent, aiAgent.Name); + + return endpoints.MapPost(pattern, ( + [FromBody] RunAgentInput? input, + [FromServices] IOptions jsonOptions, + HttpContext context, + CancellationToken cancellationToken) + => HandleRunAsync(input, hostAgent, jsonOptions, context, cancellationToken)); + } + + /// + /// Maps an AG-UI agent endpoint that resolves the agent per request via a factory + /// delegate, rather than capturing a single agent instance at startup. + /// + /// The endpoint route builder. + /// The logical agent name passed to the factory and used as the + /// resolution key and the agent's stable session identity. + /// The URL pattern for the endpoint. + /// A factory invoked once per request with the request's + /// and the , returning the + /// that handles that request. + /// An for the mapped endpoint. + /// + /// + /// Use this overload when the agent must be built with request-scoped state — per-request + /// authentication, scoped tool/MCP sessions, or conversation-scoped configuration — rather than a + /// process-wide singleton resolved once at startup (as the other MapAGUIServer overloads do). + /// The factory receives the request's + /// (), so scoped services resolve correctly within the request. + /// + /// + /// The keyed lookup and the ThreadId trust model are identical + /// to ; the store is resolved per + /// request from using as the key. + /// + /// + /// Because the agent is created per request, this overload assigns it a stable session + /// identity equal to so that + /// lookups — keyed by (agent.Id, conversationId) — stay consistent across requests and the + /// AG-UI ThreadId continues the same persisted session on every turn. Without this, each + /// request's agent would report a different per-instance and the previously + /// saved session would never be found. Any id set on the agent returned by + /// is therefore not used for session keying. + /// + /// + public static IEndpointConventionBuilder MapAGUIServer( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("route")] string pattern, + Func createAgentDelegate) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentName); + ArgumentNullException.ThrowIfNull(createAgentDelegate); + + return endpoints.MapPost(pattern, ( + [FromBody] RunAgentInput? input, + [FromServices] IOptions jsonOptions, + HttpContext context, + CancellationToken cancellationToken) => + { + var created = createAgentDelegate(context.RequestServices, agentName) + ?? throw new InvalidOperationException($"The agent factory for '{agentName}' returned null."); + + // The session store keys persisted sessions by (agent.Id, conversationId), and AIAgent.Id + // defaults to a per-instance value. Since this overload builds a fresh agent every request, + // wrap it so the id is the stable agentName; otherwise each request would key a different + // session and AG-UI thread continuation would silently reset. See StableIdentityAIAgent. + var stableAgent = new StableIdentityAIAgent(created, agentName); + var hostAgent = BuildHostAgent(context.RequestServices, stableAgent, agentName); + return HandleRunAsync(input, hostAgent, jsonOptions, context, cancellationToken); + }); + } + + private static AIHostAgent BuildHostAgent(IServiceProvider services, AIAgent agent, string? sessionStoreKey) + { + var agentSessionStore = services.GetKeyedService(sessionStoreKey); // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. - var isolationKeyProvider = endpoints.ServiceProvider.GetService(); + var isolationKeyProvider = services.GetService(); if (agentSessionStore?.GetService() is null) { agentSessionStore ??= new NoopAgentSessionStore(); agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); } - var hostAgent = new AIHostAgent(aiAgent, agentSessionStore); + return new AIHostAgent(agent, agentSessionStore); + } - return endpoints.MapPost(pattern, async ( - [FromBody] RunAgentInput? input, - [FromServices] IOptions jsonOptions, - HttpContext context, - CancellationToken cancellationToken) => + private static async Task HandleRunAsync( + RunAgentInput? input, + AIHostAgent hostAgent, + IOptions jsonOptions, + HttpContext context, + CancellationToken cancellationToken) + { + if (input is null) { - if (input is null) - { - return Results.BadRequest(); - } + return Results.BadRequest(); + } - var jsonSerializerOptions = jsonOptions.Value.SerializerOptions; - var streamOptions = context.GetEndpoint()?.Metadata.GetMetadata() - ?? context.RequestServices.GetService>()?.Value; + var jsonSerializerOptions = jsonOptions.Value.SerializerOptions; + var streamOptions = context.GetEndpoint()?.Metadata.GetMetadata() + ?? context.RequestServices.GetService>()?.Value; - var ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); + var ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); - // AG-UI continuation is keyed by thread id. When the client does not supply one, generate a - // stable id and write it back onto the input so the persisted session, the RUN_STARTED / - // RUN_FINISHED events, and any continuation the client sends back all agree on the same id. - var threadId = string.IsNullOrWhiteSpace(ctx.Input.ThreadId) ? Guid.NewGuid().ToString("N") : ctx.Input.ThreadId; - ctx.Input.ThreadId = threadId; + // AG-UI continuation is keyed by thread id. When the client does not supply one, generate a + // stable id and write it back onto the input so the persisted session, the RUN_STARTED / + // RUN_FINISHED events, and any continuation the client sends back all agree on the same id. + var threadId = string.IsNullOrWhiteSpace(ctx.Input.ThreadId) ? Guid.NewGuid().ToString("N") : ctx.Input.ThreadId; + ctx.Input.ThreadId = threadId; - var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); + var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); - var events = hostAgent - .RunStreamingAsync( - ctx.Messages, - session: session, - options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions }, - cancellationToken: cancellationToken) - .AsChatResponseUpdatesAsync() - .AsAGUIEventStreamAsync(ctx, cancellationToken); + var events = hostAgent + .RunStreamingAsync( + ctx.Messages, + session: session, + options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions }, + cancellationToken: cancellationToken) + .AsChatResponseUpdatesAsync() + .AsAGUIEventStreamAsync(ctx, cancellationToken); - // Wrap the event stream to save the session after streaming completes. - var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken); + // Wrap the event stream to save the session after streaming completes. + var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken); #if NET10_0_OR_GREATER - // On net10+ the framework provides first-class SSE result that flows through the - // configured ASP.NET Core JsonSerializerOptions (which AddAGUIServer() augments with - // AGUIJsonSerializerContext via the resolver chain). - return TypedResults.ServerSentEvents(eventsWithSessionSave); + // On net10+ the framework provides first-class SSE result that flows through the + // configured ASP.NET Core JsonSerializerOptions (which AddAGUIServer() augments with + // AGUIJsonSerializerContext via the resolver chain). + return TypedResults.ServerSentEvents(eventsWithSessionSave); #else - // On older TFMs we ship a small polyfill that emulates TypedResults.ServerSentEvents. - var sseLogger = context.RequestServices.GetRequiredService>(); - return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); + // On older TFMs we ship a small polyfill that emulates TypedResults.ServerSentEvents. + var sseLogger = context.RequestServices.GetRequiredService>(); + return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); #endif - }); } private static async IAsyncEnumerable SaveSessionAfterStreamingAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/StableIdentityAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/StableIdentityAIAgent.cs new file mode 100644 index 00000000000..8d1daf23559 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/StableIdentityAIAgent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +/// +/// Wraps a per-request so that its reports a +/// caller-supplied stable value instead of the per-instance identifier the base type generates. +/// +/// +/// implementations key persisted sessions by the pair +/// (agent.Id, conversationId), and defaults to a value that is unique +/// per agent instance. The per-request factory overload of MapAGUIServer builds a fresh agent on +/// every request, so without a stable identity each request would compute a different session key and +/// the previously persisted session would never be found — silently resetting AG-UI thread +/// continuation every turn. Wrapping the per-request agent so its id is the logical agent name keeps +/// the session key constant across requests, matching the startup-capture overloads where a single +/// agent instance is reused. +/// +internal sealed class StableIdentityAIAgent : DelegatingAIAgent +{ + private readonly string stableId; + + /// + /// Initializes a new instance of the class. + /// + /// The per-request agent to delegate all behavior to. + /// The stable identifier to report from . + public StableIdentityAIAgent(AIAgent innerAgent, string stableId) + : base(innerAgent) + { + this.stableId = stableId; + } + + /// + protected override string? IdCore => this.stableId; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/MapAGUIFactoryDelegateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/MapAGUIFactoryDelegateTests.cs new file mode 100644 index 00000000000..deb525bbdb1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/MapAGUIFactoryDelegateTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AGUI.Client; +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; + +/// +/// Integration tests for the per-request factory-delegate overload of +/// MapAGUIServer(endpoints, agentName, pattern, Func<IServiceProvider, string, AIAgent>). +/// Unlike the startup-capture overloads, the factory is invoked once per request from the request's +/// . +/// +public sealed class MapAGUIFactoryDelegateTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task MapAGUIServer_WithFactoryDelegate_InvokesFactoryPerRequest_AndStreamsAsync() + { + // Arrange - map the endpoint with a factory that records how many times it is invoked. + int factoryInvocations = 0; + await this.SetupTestServerWithFactoryAsync((_, name) => + { + Interlocked.Increment(ref factoryInvocations); + return new FakeSessionAgent(name); + }); + + var chatClient = new AGUIChatClient(new(this._client!, "")); + AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + AgentSession session = await agent.CreateSessionAsync(); + + // Act - two turns => two HTTP requests. + List firstTurn = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "First")], session, new AgentRunOptions(), CancellationToken.None)) + { + firstTurn.Add(update); + } + + List secondTurn = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Second")], session, new AgentRunOptions(), CancellationToken.None)) + { + secondTurn.Add(update); + } + + // Assert - the factory ran once per request (not captured once at startup), and the agent streamed. + factoryInvocations.Should().Be(2, "the factory delegate is invoked per request"); + firstTurn.Should().NotBeEmpty(); + firstTurn.ToAgentResponse().Messages[0].Text.Should().Contain("Hello from session agent"); + } + + [Fact] + public async Task MapAGUIServer_WithFactoryDelegate_WhenFactoryReturnsNull_FailsTheRequestAsync() + { + // Arrange - a factory that returns null should surface a clear failure when a request arrives. + await this.SetupTestServerWithFactoryAsync((_, _) => null!); + + const string Json = """ + {"threadId":"t1","runId":"r1","messages":[{"id":"m1","role":"user","content":"hi"}],"tools":[],"context":[],"state":{}} + """; + using StringContent content = new(Json, Encoding.UTF8, "application/json"); + + // Act + Func act = async () => + { + using HttpResponseMessage response = await this._client!.PostAsync((Uri?)null, content); + response.EnsureSuccessStatusCode(); + }; + + // Assert - the factory returning null surfaces a clear InvalidOperationException naming the agent. + (await act.Should().ThrowAsync()) + .WithMessage("*factory for 'factory-agent' returned null*"); + } + + private async Task SetupTestServerWithFactoryAsync(Func factory) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddAGUIServer(); + + this._app = builder.Build(); + + // Per-request factory overload - no keyed AIAgent registration required. + this._app.MapAGUIServer("factory-agent", "/agent", factory); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._client = testServer.CreateClient(); + this._client.BaseAddress = new Uri("http://localhost/agent"); + } + + public async ValueTask DisposeAsync() + { + this._client?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index d22297c01e9..eccc1896704 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -188,6 +188,170 @@ public void MapAGUIServer_WithNullAgentBuilder_ThrowsArgumentNullException() endpointsMock.Object.MapAGUIServer((IHostedAgentBuilder)null!, "/api/agent")); } + [Fact] + public void MapAGUIServer_WithFactoryDelegate_MapsEndpoint_AtSpecifiedPattern() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer( + "test-agent", "/api/agent", static (_, _) => new NamedTestAgent()); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void MapAGUIServer_WithFactoryDelegate_DefersResolution_DoesNotInvokeFactoryAtMapTime() + { + // Arrange — the factory overload resolves the agent per request, so mapping must NOT invoke the + // factory nor resolve a keyed AIAgent from DI (unlike the startup-capture agentName overload). + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + int factoryInvocations = 0; + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer( + "test-agent", + "/api/agent", + (_, _) => + { + factoryInvocations++; + return new NamedTestAgent(); + }); + + // Assert + Assert.NotNull(result); + Assert.Equal(0, factoryInvocations); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), It.IsAny()), Times.Never); + } + + [Fact] + public void MapAGUIServer_WithNullFactoryDelegate_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUIServer("test-agent", "/api/agent", (Func)null!)); + } + + [Fact] + public void MapAGUIServer_FactoryOverload_StableIdentity_ReportsAgentNameAcrossInnerInstances() + { + // Arrange — two per-request agent instances have distinct default ids (AIAgent.Id is per-instance). + MarkerAgent inner1 = new(); + MarkerAgent inner2 = new(); + Assert.NotEqual(inner1.Id, inner2.Id); + + // Act — wrap each with a stable identity keyed by the logical agent name. + StableIdentityAIAgent wrapped1 = new(inner1, "orders-agent"); + StableIdentityAIAgent wrapped2 = new(inner2, "orders-agent"); + + // Assert — both report the stable name, so session keys stay constant across requests. + Assert.Equal("orders-agent", wrapped1.Id); + Assert.Equal(wrapped1.Id, wrapped2.Id); + } + + [Fact] + public async Task MapAGUIServer_FactoryOverload_PersistsSessionAcrossPerRequestAgentInstancesAsync() + { + // Arrange — the factory overload builds a fresh agent per request. The session store keys by + // (agent.Id, conversationId), so a stable identity is required for AG-UI thread continuation to + // recover the previously persisted session. This reproduces the two-turn flow. + InMemoryAgentSessionStore store = new(); + const string AgentName = "orders-agent"; + const string ThreadId = "thread-1"; + + // Turn 1: fresh per-request agent, wrapped with the stable identity, saves a marker. + MarkerAgent turn1Inner = new(); + StableIdentityAIAgent turn1 = new(turn1Inner, AgentName); + AgentSession session1 = await turn1.CreateSessionAsync(); + session1.StateBag.SetValue("marker", "persisted"); + await store.SaveSessionAsync(turn1, ThreadId, session1); + + // Turn 2: a different per-request agent instance, same logical name. + MarkerAgent turn2Inner = new(); + Assert.NotEqual(turn1Inner.Id, turn2Inner.Id); + StableIdentityAIAgent turn2 = new(turn2Inner, AgentName); + + // Act — recover the session for the same thread id through the second request's agent. + AgentSession session2 = await store.GetSessionAsync(turn2, ThreadId); + + // Assert — the stable identity keeps the key constant, so turn 2 recovers turn 1's session. + Assert.True(session2.StateBag.TryGetValue("marker", out string? marker)); + Assert.Equal("persisted", marker); + } + + [Fact] + public async Task MapAGUIServer_FactoryOverload_WithoutStableIdentity_LosesSessionAcrossInstancesAsync() + { + // Arrange — demonstrates the failure mode the stable identity prevents: two raw per-request + // agent instances have different ids, so the session store keys them separately. + InMemoryAgentSessionStore store = new(); + const string ThreadId = "thread-1"; + + MarkerAgent raw1 = new(); + AgentSession session1 = await raw1.CreateSessionAsync(); + session1.StateBag.SetValue("marker", "persisted"); + await store.SaveSessionAsync(raw1, ThreadId, session1); + + MarkerAgent raw2 = new(); + Assert.NotEqual(raw1.Id, raw2.Id); + + // Act + AgentSession session2 = await store.GetSessionAsync(raw2, ThreadId); + + // Assert — the marker is lost because the per-instance id changed the session key. + Assert.False(session2.StateBag.TryGetValue("marker", out _)); + } + + private sealed class MarkerSession : AgentSession + { + public MarkerSession() + { + } + + public MarkerSession(AgentSessionStateBag stateBag) + : base(stateBag) + { + } + } + + private sealed class MarkerAgent : AIAgent + { + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new MarkerSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(session.StateBag.Serialize()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new MarkerSession(AgentSessionStateBag.Deserialize(serializedState))); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + private sealed class TestAgent : AIAgent { protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();