Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs
Ben Thomas 0bbedc4fa2 .NET: Fix/per service input persistence on stream error (#5744)
* .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>
2026-05-11 20:28:14 +00:00

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>());
}
}