mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
0bbedc4fa2
* .NET: Persist input messages on streaming errors in PerServiceCallChatHistoryPersistingChatClient When the underlying chat service emits an in-stream error (for example a `response.error` SSE event from the OpenAI Responses API on rate limit), the OpenAI client surfaces it as an `ErrorContent` update and ends the stream without throwing. Previously, `PerServiceCallChatHistoryPersistingChatClient` only persisted history when the streaming loop completed successfully and `NotifyProvidersOfNewMessagesAsync` was called at the end. On the in-stream-error path, the input messages handed to that iteration - typically `FunctionResultContent` produced by `FunctionInvokingChatClient` in the previous iteration - were never persisted. The next run would replay session history with a dangling `FunctionCallContent` and the service would reject the request with `No tool output found for function call <id>`. This change: - Adds a `PersistInputOnErrorAsync` helper that persists the input messages (with no response messages) so function-call/function-result pairings are not split across failures. - Calls the helper from every error path: pre-loop enumerator creation, the first `MoveNextAsync`, the in-loop `MoveNextAsync`, and a new `finally` that handles abnormal iterator disposal. - After the streaming loop, scans the assembled response for any `ErrorContent` and, if present, persists the input, notifies providers of failure, and throws `InvalidOperationException` so the error is surfaced to the caller instead of silently corrupting history. - Hardens `InMemoryChatHistoryProvider.StoreChatHistoryAsync` to treat a null `RequestMessages` as empty, since the new error path can invoke it with no response messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix dropped FunctionResultContent on streaming pipeline early-disposal When a consumer of ChatClientAgent.RunStreamingAsync stops iterating early (e.g. ToolApprovalAgent yields the approval request and then `yield break`), the framework cascades DisposeAsync down the stream. C# async iterators do not auto-dispose IAsyncDisposable locals, so the inner enumerator returned by IChatClient.GetStreamingResponseAsync(...).GetAsyncEnumerator(ct) was left suspended. That suspended FunctionInvokingChatClient downstream, which suspended PerServiceCallChatHistoryPersistingChatClient at its `yield return`, so its finally block never ran and the in-flight FunctionResultContent for the just-completed tool call was not persisted to chat history. The next turn then loaded a session that contained a FunctionCallContent with no matching FunctionResultContent and the model returned HTTP 400 `No tool output found for function call`. Fixes: * ChatClientAgent.RunStreamingAsync: wrap the iteration in try/finally that disposes the inner enumerator. Disposal now cascades through the pipeline and PerService's finally runs on early exit. * PerServiceCallChatHistoryPersistingChatClient: in the streaming path, snapshot input messages with `messages.ToList()` (the caller, FICC, reuses a single mutable buffer across iterations and may mutate it before our finally / error path persists), wrap GetAsyncEnumerator, the first MoveNextAsync, and in-loop MoveNextAsync in try/catch each calling PersistInputOnErrorAsync + NotifyProvidersOfFailureAsync, and add a finally that calls PersistInputOnErrorAsync when the loop did not exit normally so per-iteration FRCs are persisted on early disposal as well as on errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Add tests for PerService streaming error/dispose persistence paths Adds five regression tests covering the new error-path persistence in PerServiceCallChatHistoryPersistingChatClient.GetStreamingResponseInnerAsync: - Persists input messages when GetStreamingResponseAsync throws synchronously. - Persists input messages when the first MoveNextAsync throws. - Persists input messages when a mid-stream MoveNextAsync throws. - Persists input messages when the consumer abandons enumeration early (the ToolApprovalAgent yield-break / disposal-cascade case). - Throws and persists input when the stream emits an in-band ErrorContent. All 66 tests in the class pass on net10.0 and net472. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Address PR feedback on PerService streaming error persistence Two follow-ups from PR #5744 review: 1. Prevent duplicate persistence on the in-loop MoveNextAsync catch path. The inner catch persists input messages, then rethrows, which propagates through the surrounding try/finally where loopExitedNormally is still false, causing the finally to persist again. Introduced an inputPersisted flag that the inner catch sets after persisting; the finally now skips when inputPersisted is true. 2. Use the caller's CancellationToken in the abnormal-exit finally instead of CancellationToken.None, so cleanup remains responsive to cancellation. Fall back to CancellationToken.None only when the caller's token is already canceled (otherwise the persist call would observe the cancellation, throw, and mask the original early-exit reason). Tightened all five new streaming-error tests from Times.AtLeastOnce to Times.Once on the input-persistence matcher to regression-guard against duplicate persistence. All 66 tests in the class still pass (net10.0 + net472). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Scope PerService streaming changes to cooperative early-exit only Per discussion on PR #5744, scope this PR back to fix only the original ToolApprovalAgent dropped-FunctionResultContent bug and address the enumerator-disposal review comment. Specifically: - Remove input-message persistence from the GetAsyncEnumerator and MoveNextAsync error paths. Routing failed service calls through the success notification channel was breaking the provider contract; we will instead rely on inner-agent retries for transient errors. Failure paths still call NotifyProvidersOfFailureAsync as before. - Remove the in-stream ErrorContent detection block (same rationale). - Keep the try/finally that calls the (now narrower) early-exit input notification on cooperative disposal (e.g. ToolApprovalAgent yield break). A new serviceErrorOccurred flag ensures we do NOT renotify on exception paths. - Always DisposeAsync the underlying enumerator on every exit path, addressing the copilot-reviewer comment about leaked HTTP/streams. - Rename PersistInputOnErrorAsync -> NotifyProvidersOfEarlyExitInputAsync to better reflect what it does and when it runs (rogerbarreto nit). - Apply rogerbarreto nit on InMemoryChatHistoryProvider null-coalescing. - Drop the four tests that covered the removed error-path behavior; keep RunStreamingAsync_PersistsInputMessages_WhenConsumerAbandons EnumerationAsync (regression guard for the cooperative-pause path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1375 lines
62 KiB
C#
1375 lines
62 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.AI;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Moq;
|
|
using Moq.Protected;
|
|
|
|
namespace Microsoft.Agents.AI.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Contains unit tests for the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator,
|
|
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
|
|
/// individual service call by default, or marks messages for end-of-run persistence when the
|
|
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> option is enabled.
|
|
/// </summary>
|
|
public class PerServiceCallChatHistoryPersistingChatClientTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
|
|
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — InvokedCoreAsync should be called by the decorator (per service call)
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
|
x.RequestMessages.Any(m => m.Text == "test") &&
|
|
x.ResponseMessages!.Any(m => m.Text == "response")),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active (default),
|
|
/// the ChatHistoryProvider receives messages at the end of the run.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionEnabledAsync()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — InvokedCoreAsync should be called once by the agent (end of run)
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
|
x.RequestMessages.Any(m => m.Text == "test") &&
|
|
x.ResponseMessages!.Any(m => m.Text == "response")),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active (default) and the service call fails,
|
|
/// the ChatHistoryProvider is notified with the exception.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_NotifiesProviderOfFailure_WhenPerServiceCallPersistenceActiveAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Service failed");
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>())).ThrowsAsync(expectedException);
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
|
|
|
// Assert — the decorator should have notified the provider of the failure
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
|
x.InvokeException != null &&
|
|
x.InvokeException.Message == "Service failed"),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChatClient_DoesNotContainDecorator_ByDefault()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
|
|
// Act
|
|
ChatClientAgent agent = new(mockService.Object, options: new());
|
|
|
|
// Assert
|
|
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
|
|
Assert.Null(decorator);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
|
|
// Act
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Assert
|
|
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
|
|
Assert.NotNull(decorator);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the decorator is NOT injected when UseProvidedChatClientAsIs is true.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChatClient_DoesNotContainDecorator_WhenUseProvidedChatClientAsIs()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
|
|
// Act
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
UseProvidedChatClientAsIs = true,
|
|
});
|
|
|
|
// Assert
|
|
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
|
|
Assert.Null(decorator);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone().
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence()
|
|
{
|
|
// Arrange
|
|
var options = new ChatClientAgentOptions
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
};
|
|
|
|
// Act
|
|
var cloned = options.Clone();
|
|
|
|
// Assert
|
|
Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active (default) and the service call
|
|
/// involves a function invocation loop, the ChatHistoryProvider is called after each individual
|
|
/// service call (not just once at the end).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAsync()
|
|
{
|
|
// Arrange
|
|
int serviceCallCount = 0;
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
serviceCallCount++;
|
|
if (serviceCallCount == 1)
|
|
{
|
|
// First call returns a tool call
|
|
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
|
}
|
|
|
|
// Second call returns a final response
|
|
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
|
|
});
|
|
|
|
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
|
.Returns(() => new ValueTask());
|
|
|
|
// Define a simple tool
|
|
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatOptions = new() { Tools = [tool] },
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
}, services: new ServiceCollection().BuildServiceProvider());
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
Exception? caughtException = null;
|
|
try
|
|
{
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
caughtException = ex;
|
|
}
|
|
|
|
// Diagnostic: check if there was an unexpected exception
|
|
Assert.Null(caughtException);
|
|
|
|
// Assert — the decorator should have been called twice (once per service call in the function invocation loop)
|
|
Assert.Equal(2, serviceCallCount);
|
|
Assert.Equal(2, invokedContexts.Count);
|
|
|
|
// First invocation should have the user message as request and tool call response
|
|
Assert.NotNull(invokedContexts[0].ResponseMessages);
|
|
var firstRequestMessages = invokedContexts[0].RequestMessages.ToList();
|
|
Assert.Contains(firstRequestMessages, m => m.Text == "test");
|
|
Assert.Contains(invokedContexts[0].ResponseMessages!, m => m.Contents.OfType<FunctionCallContent>().Any());
|
|
|
|
// Second invocation: request messages should NOT include the original user message (already notified).
|
|
// It should only include messages added since the first call (assistant tool call + tool result).
|
|
Assert.NotNull(invokedContexts[1].ResponseMessages);
|
|
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
|
Assert.DoesNotContain(secondRequestMessages, m => m.Text == "test");
|
|
Assert.Contains(invokedContexts[1].ResponseMessages!, m => m.Text == "final response");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active (default) with streaming,
|
|
/// the ChatHistoryProvider receives messages after the stream completes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetStreamingResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(CreateAsyncEnumerableAsync(
|
|
new ChatResponseUpdate(ChatRole.Assistant, "streaming "),
|
|
new ChatResponseUpdate(ChatRole.Assistant, "response")));
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
|
|
{
|
|
// Consume stream
|
|
}
|
|
|
|
// Assert — InvokedCoreAsync should be called by the decorator
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
|
x.RequestMessages.Any(m => m.Text == "test") &&
|
|
x.ResponseMessages != null),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active (default),
|
|
/// AIContextProviders are also notified of new messages after a successful call.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_NotifiesAIContextProviders_ByDefaultAsync()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — InvokedCoreAsync should be called by the decorator for the AIContextProvider
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
|
x.ResponseMessages != null &&
|
|
x.ResponseMessages.Any(m => m.Text == "response")),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active (default) and the service fails,
|
|
/// AIContextProviders are notified of the failure.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_NotifiesAIContextProvidersOfFailure_ByDefaultAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Service failed");
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>())).ThrowsAsync(expectedException);
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
|
|
|
// Assert — the decorator should have notified the AIContextProvider of the failure
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
|
x.InvokeException != null &&
|
|
x.InvokeException.Message == "Service failed"),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active (default),
|
|
/// both ChatHistoryProvider and AIContextProviders are notified together.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_NotifiesBothProviders_ByDefaultAsync()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => new ValueTask());
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — both providers should have been notified
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
|
x.ResponseMessages != null &&
|
|
x.ResponseMessages.Any(m => m.Text == "response")),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
|
x.ResponseMessages != null &&
|
|
x.ResponseMessages.Any(m => m.Text == "response")),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that during a FIC loop, response messages from the first call are not
|
|
/// re-notified as request messages on the second call.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_DoesNotReNotifyResponseMessagesAsRequestMessages_DuringFicLoopAsync()
|
|
{
|
|
// Arrange
|
|
int serviceCallCount = 0;
|
|
var assistantToolCallMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())]);
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
serviceCallCount++;
|
|
if (serviceCallCount == 1)
|
|
{
|
|
return Task.FromResult(new ChatResponse([assistantToolCallMessage]));
|
|
}
|
|
|
|
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
|
|
});
|
|
|
|
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
|
.Returns(() => new ValueTask());
|
|
|
|
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatOptions = new() { Tools = [tool] },
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
}, services: new ServiceCollection().BuildServiceProvider());
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert
|
|
Assert.Equal(2, invokedContexts.Count);
|
|
|
|
// The assistant tool call message was a response in call 1
|
|
Assert.Contains(invokedContexts[0].ResponseMessages!, m => ReferenceEquals(m, assistantToolCallMessage));
|
|
|
|
// It should NOT appear as a request in call 2 (it was already notified as a response)
|
|
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
|
Assert.DoesNotContain(secondRequestMessages, m => ReferenceEquals(m, assistantToolCallMessage));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when a failure occurs on the second call in a FIC loop,
|
|
/// only new request messages (not previously notified) are sent in the failure notification.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_DeduplicatesRequestMessages_OnFailureDuringFicLoopAsync()
|
|
{
|
|
// Arrange
|
|
int serviceCallCount = 0;
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
serviceCallCount++;
|
|
if (serviceCallCount == 1)
|
|
{
|
|
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
|
}
|
|
|
|
throw new InvalidOperationException("Service failure on second call");
|
|
});
|
|
|
|
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
|
.Returns(() => new ValueTask());
|
|
|
|
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatOptions = new() { Tools = [tool] },
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
}, services: new ServiceCollection().BuildServiceProvider());
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
agent.RunAsync([new(ChatRole.User, "test")], session));
|
|
|
|
// Assert — should have 2 notifications: success on call 1, failure on call 2
|
|
Assert.Equal(2, invokedContexts.Count);
|
|
|
|
// First notification: success, has user message as request
|
|
Assert.Null(invokedContexts[0].InvokeException);
|
|
Assert.Contains(invokedContexts[0].RequestMessages, m => m.Text == "test");
|
|
|
|
// Second notification: failure, should NOT include the user message (already notified)
|
|
Assert.NotNull(invokedContexts[1].InvokeException);
|
|
var failureRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
|
Assert.DoesNotContain(failureRequestMessages, m => m.Text == "test");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that after a successful run with per-service-call persistence, the notified
|
|
/// messages are stamped with the persisted marker so they are not re-notified.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Verifies that when the inner client returns a real conversation ID,
|
|
/// the session's ConversationId is updated after the run.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_UpdatesSessionConversationId_WhenServiceReturnsOneAsync()
|
|
{
|
|
// Arrange
|
|
const string ExpectedConversationId = "conv-123";
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
|
{
|
|
ConversationId = ExpectedConversationId,
|
|
});
|
|
|
|
ChatClientAgent agent = new(mockService.Object);
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — session should have the conversation ID returned by the inner client
|
|
Assert.Equal(ExpectedConversationId, session!.ConversationId);
|
|
}
|
|
|
|
private static async IAsyncEnumerable<ChatResponseUpdate> CreateAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
|
|
{
|
|
foreach (var update in updates)
|
|
{
|
|
yield return update;
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
|
|
/// <see cref="ChatClientAgent"/> sets the <see cref="PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
|
|
/// sentinel on the chat options and <see cref="PerServiceCallChatHistoryPersistingChatClient"/> strips it before
|
|
/// forwarding to the inner client.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
|
|
{
|
|
// Arrange
|
|
ChatOptions? capturedOptions = null;
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatOptions = new() { Instructions = "test" },
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
await agent.RunAsync([new(ChatRole.User, "test")]);
|
|
|
|
// Assert — the inner client should NOT see the sentinel conversation ID
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.Null(capturedOptions!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the sentinel is NOT set when end-of-run persistence is enabled
|
|
/// (mark-only mode), since the issue only applies to per-service-call persistence.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync()
|
|
{
|
|
// Arrange
|
|
ChatOptions? capturedOptions = null;
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatOptions = new() { Instructions = "test" },
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
await agent.RunAsync([new(ChatRole.User, "test")]);
|
|
|
|
// Assert — the inner client should see options but NOT the sentinel conversation ID
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.Null(capturedOptions!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the sentinel is NOT set when a real conversation ID is already present
|
|
/// on the session (indicating server-side history management).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync()
|
|
{
|
|
// Arrange
|
|
const string RealConversationId = "real-conv-123";
|
|
ChatOptions? capturedOptions = null;
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
|
{
|
|
ConversationId = RealConversationId,
|
|
});
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Create a session with a real conversation ID.
|
|
var session = await agent.CreateSessionAsync(RealConversationId);
|
|
|
|
// Act
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — the inner client should see the real conversation ID, not the sentinel
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.Equal(RealConversationId, capturedOptions!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the sentinel is set and stripped correctly in the streaming path.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
|
|
{
|
|
// Arrange
|
|
ChatOptions? capturedOptions = null;
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetStreamingResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
|
.Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")));
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatOptions = new() { Instructions = "test" },
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")]))
|
|
{
|
|
// Consume the stream.
|
|
}
|
|
|
|
// Assert — the inner client should NOT see the sentinel conversation ID
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.Null(capturedOptions!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the session's conversation ID IS set to the sentinel after the run
|
|
/// when simulating service-stored chat history. This allows subsequent runs to
|
|
/// skip provider resolution in the agent (the decorator handles it).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — session should have the sentinel conversation ID
|
|
Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when simulating service-stored chat history and the service returns
|
|
/// a real <see cref="ChatResponse.ConversationId"/>, the conflict detection in
|
|
/// <see cref="ChatClientAgent.UpdateSessionConversationId"/> throws because both a
|
|
/// <see cref="ChatHistoryProvider"/> and a service-managed ConversationId are present.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_Throws_WhenServiceReturnsRealConversationIdWithChatHistoryProviderAsync()
|
|
{
|
|
// Arrange
|
|
const string RealConversationId = "service-conv-456";
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
|
{
|
|
ConversationId = RealConversationId,
|
|
});
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act & Assert — conflict detection should throw
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when simulating service-stored chat history and the request carries a real
|
|
/// <see cref="ChatOptions.ConversationId"/>, the decorator skips history loading but still
|
|
/// notifies <see cref="AIContextProvider"/>s on success and updates the session ConversationId.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
|
|
{
|
|
// Arrange
|
|
const string RealConversationId = "real-conv-request";
|
|
const string ServiceConversationId = "real-conv-response";
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
|
{
|
|
ConversationId = ServiceConversationId,
|
|
});
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
});
|
|
|
|
// Create a session with a real conversation ID so it's on chatOptions.
|
|
var session = await agent.CreateSessionAsync(RealConversationId);
|
|
|
|
// Act
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — AIContextProvider.InvokedAsync should have been called
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
|
x.RequestMessages.Any(m => m.Text == "test") &&
|
|
x.ResponseMessages!.Any(m => m.Text == "response")),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
|
|
// Assert — session should have the service-returned ConversationId
|
|
Assert.Equal(ServiceConversationId, (session as ChatClientAgentSession)!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when simulating service-stored chat history and the request carries a real
|
|
/// <see cref="ChatOptions.ConversationId"/>, the decorator notifies providers of failure
|
|
/// when the inner client throws.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_NotifiesProvidersOfFailure_WhenRequestHasRealConversationIdAsync()
|
|
{
|
|
// Arrange
|
|
const string RealConversationId = "real-conv-failure";
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Service error"));
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
});
|
|
|
|
var session = await agent.CreateSessionAsync(RealConversationId);
|
|
|
|
// Act & Assert — should throw
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
|
|
|
// Assert — AIContextProvider.InvokedAsync should have been called with the failure
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<AIContextProvider.InvokedContext>(x => x.InvokeException != null),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in the streaming path, when the request carries a real
|
|
/// <see cref="ChatOptions.ConversationId"/>, the decorator skips history loading but still
|
|
/// notifies providers and updates the session ConversationId.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
|
|
{
|
|
// Arrange
|
|
const string RealConversationId = "real-conv-streaming";
|
|
const string ServiceConversationId = "service-conv-streaming";
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetStreamingResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(CreateAsyncEnumerableAsync(
|
|
new ChatResponseUpdate(ChatRole.Assistant, "streamed") { ConversationId = ServiceConversationId }));
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
});
|
|
|
|
var session = await agent.CreateSessionAsync(RealConversationId);
|
|
|
|
// Act
|
|
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
|
|
{
|
|
// Consume all updates.
|
|
}
|
|
|
|
// Assert — AIContextProvider.InvokedAsync should have been called
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.IsAny<AIContextProvider.InvokedContext>(),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
|
|
// Assert — session should have the service-returned ConversationId
|
|
Assert.Equal(ServiceConversationId, (session as ChatClientAgentSession)!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when simulating and the service unexpectedly returns a real
|
|
/// <see cref="ChatResponse.ConversationId"/> (no ConversationId on the request), the decorator
|
|
/// notifies providers and updates the session ConversationId without setting the sentinel.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_NotifiesProvidersAndUpdatesSession_WhenServiceReturnsUnexpectedConversationIdAsync()
|
|
{
|
|
// Arrange
|
|
const string ServiceConversationId = "unexpected-conv-id";
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
|
{
|
|
ConversationId = ServiceConversationId,
|
|
});
|
|
|
|
// No ChatHistoryProvider — so conflict detection won't throw.
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
|
|
|
// Assert — AIContextProvider.InvokedAsync should have been called
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
|
x.ResponseMessages!.Any(m => m.Text == "response")),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
|
|
// Assert — session should have the service ConversationId, not the sentinel
|
|
Assert.Equal(ServiceConversationId, session!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in the streaming path, when the service returns a real ConversationId mid-stream
|
|
/// (no ConversationId on the request), the decorator notifies providers and updates the session.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_NotifiesProvidersAndUpdatesSession_WhenServiceReturnsUnexpectedConversationIdAsync()
|
|
{
|
|
// Arrange
|
|
const string ServiceConversationId = "unexpected-stream-conv";
|
|
|
|
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
|
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
|
|
mockContextProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetStreamingResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(CreateAsyncEnumerableAsync(
|
|
new ChatResponseUpdate(ChatRole.Assistant, "part1"),
|
|
new ChatResponseUpdate(null, "part2") { ConversationId = ServiceConversationId }));
|
|
|
|
// No ChatHistoryProvider — so conflict detection won't throw.
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
AIContextProviders = [mockContextProvider.Object],
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
|
|
{
|
|
// Consume all updates.
|
|
}
|
|
|
|
// Assert — AIContextProvider.InvokedAsync should have been called
|
|
mockContextProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.IsAny<AIContextProvider.InvokedContext>(),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
|
|
// Assert — session should have the service ConversationId, not the sentinel
|
|
Assert.Equal(ServiceConversationId, session!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when <see cref="ChatOptions.AllowBackgroundResponses"/> is true,
|
|
/// the decorator skips history loading and sentinel setting, letting the agent's
|
|
/// forced end-of-run path handle persistence.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync()
|
|
{
|
|
// Arrange
|
|
IEnumerable<ChatMessage>? capturedMessages = null;
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) => capturedMessages = msgs)
|
|
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
{
|
|
// Add a history message to verify it's NOT prepended in this scenario.
|
|
var result = ctx.RequestMessages.ToList();
|
|
result.Insert(0, new ChatMessage(ChatRole.Assistant, "history"));
|
|
return new ValueTask<IEnumerable<ChatMessage>>(result);
|
|
});
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await agent.RunAsync(
|
|
[new(ChatRole.User, "test")],
|
|
session,
|
|
new AgentRunOptions { AllowBackgroundResponses = true });
|
|
|
|
// Assert — the inner client should NOT have received history messages
|
|
Assert.NotNull(capturedMessages);
|
|
var messageList = capturedMessages!.ToList();
|
|
Assert.Single(messageList);
|
|
Assert.Equal("test", messageList[0].Text);
|
|
|
|
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
|
|
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in the streaming path, when <see cref="ChatOptions.AllowBackgroundResponses"/> is true,
|
|
/// the decorator skips history loading and sentinel setting.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync()
|
|
{
|
|
// Arrange
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetStreamingResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(ChatRole.Assistant, "response")));
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
List<AgentResponseUpdate> updates = [];
|
|
await foreach (var update in agent.RunStreamingAsync(
|
|
[new(ChatRole.User, "test")],
|
|
session,
|
|
new AgentRunOptions { AllowBackgroundResponses = true }))
|
|
{
|
|
updates.Add(update);
|
|
}
|
|
|
|
// Assert — updates should NOT carry the sentinel ConversationId
|
|
Assert.NotEmpty(updates);
|
|
|
|
// Assert — session should NOT have the sentinel
|
|
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the consumer abandons enumeration early (the streaming enumerator is
|
|
/// disposed before completing — e.g. <c>ToolApprovalAgent.RunStreamingAsync</c> doing a
|
|
/// <c>yield break</c>), the decorator still persists the input messages via its <c>finally</c>
|
|
/// block. This regression-guards the dropped-FunctionResultContent → HTTP 400 bug.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_PersistsInputMessages_WhenConsumerAbandonsEnumerationAsync()
|
|
{
|
|
// Arrange — emit multiple updates so the consumer can break after the first.
|
|
Mock<IChatClient> mockService = new();
|
|
mockService.Setup(
|
|
s => s.GetStreamingResponseAsync(
|
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
|
It.IsAny<ChatOptions>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(CreateAsyncEnumerableAsync(
|
|
new ChatResponseUpdate(ChatRole.Assistant, "first "),
|
|
new ChatResponseUpdate(ChatRole.Assistant, "second "),
|
|
new ChatResponseUpdate(ChatRole.Assistant, "third")));
|
|
|
|
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
|
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
|
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
|
.Returns(new ValueTask());
|
|
|
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
|
{
|
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
|
RequirePerServiceCallChatHistoryPersistence = true,
|
|
});
|
|
|
|
// Act — consumer breaks out after the first update, mirroring ToolApprovalAgent's
|
|
// yield-break-on-approval-required path.
|
|
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
|
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "frc-input")], session))
|
|
{
|
|
break;
|
|
}
|
|
|
|
// Assert — even though the consumer abandoned the stream, the input messages
|
|
// must still have been persisted (so we don't lose function-call/function-result
|
|
// pairings).
|
|
mockChatHistoryProvider
|
|
.Protected()
|
|
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
|
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
|
x.RequestMessages.Any(m => m.Text == "frc-input") &&
|
|
(x.ResponseMessages == null || !x.ResponseMessages.Any()) &&
|
|
x.InvokeException == null),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
}
|