.NET: [Bug]: RequirePerServiceCallChatHistoryPersistence duplicates chat history with background responses and continuation tokens #9793
Description
Observed Behavior
When ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence is true, runs that use background responses (AllowBackgroundResponses = true) or that are resumed with a continuation token persist the same messages more than once to the ChatHistoryProvider.
This happens even when the run completes normally, without any interruption. With RequirePerServiceCallChatHistoryPersistence = false (end-of-run persistence), the same scenarios store each message exactly once.
Results with the default InMemoryChatHistoryProvider and a fake IChatClient (user message "Hello"; non-streaming responses return "done"; streaming responses return "partial" with a continuation token, then " rest"):
| Scenario | Per-service-call off | Per-service-call on |
|---|---|---|
RunAsync, AllowBackgroundResponses = true, completes in one call |
user "Hello", assistant "done" |
user "Hello", assistant "done", user "Hello", assistant "done" |
RunAsync, AllowBackgroundResponses = true, completed after one poll with the continuation token |
user "Hello", assistant "done" |
user "Hello", user "Hello", assistant "done", assistant "done" |
RunStreamingAsync, AllowBackgroundResponses = true, completes without interruption |
user "Hello", assistant "partial rest" |
user "Hello", assistant "partial rest", user "Hello", assistant "partial rest" |
RunStreamingAsync, consumer breaks after an update carrying a continuation token, then resumes with it (with or without AllowBackgroundResponses) |
user "Hello", assistant "partial rest" |
user "Hello", assistant " rest", user "Hello", assistant "partial rest" |
RunStreamingAsync, caller cancels after an update carrying a continuation token, then resumes with it (with or without AllowBackgroundResponses) |
user "Hello", assistant "partial rest" |
assistant " rest", user "Hello", assistant "partial rest" |
RunStreamingAsync, continuation token received without AllowBackgroundResponses, completes without interruption |
user "Hello", assistant "partial rest" |
user "Hello", assistant "partial rest" (correct) |
Impact: the duplicated messages are sent back to the model on the next turn, and the stored history no longer matches the actual conversation.
Expected Behavior
Each message should be persisted exactly once, as with end-of-run persistence. With RequirePerServiceCallChatHistoryPersistence = true, background-response and continuation runs are documented to fall back to end-of-run persistence (see the LogAgentChatClientBackgroundResponseFallback warning and the RunAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync test: "letting the agent's forced end-of-run path handle persistence"), so PerServiceCallChatHistoryPersistingChatClient should not notify providers in these runs.
Steps to Reproduce
- Save the code in the Minimal Reproduction section as
repro.cs. - Run it as a file-based app with the .NET 10 SDK:
dotnet run repro.cs. - Observe the printed chat history:
- Scenario 1 (
RunAsyncwithAllowBackgroundResponses = true, completed in a single call): the user message and the assistant response are stored twice. - Scenario 2 (
RunStreamingAsyncstopped after an update carrying aContinuationToken, then resumed with it): the user message is stored twice, and the resumed part of the response is stored both on its own and merged into the full response.
- Scenario 1 (
Minimal Reproduction
File-based app, run with dotnet run repro.cs (.NET 10 SDK):
#:package Microsoft.Agents.AI@1.26.0
#pragma warning disable MEAI001 // Continuation tokens are experimental.
using System.Runtime.CompilerServices;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
// Scenario 1: AllowBackgroundResponses, the response completes in a single call.
var agent = new ChatClientAgent(new FakeChatClient(), options: new() { RequirePerServiceCallChatHistoryPersistence = true });
var session = await agent.CreateSessionAsync();
await agent.RunAsync([new(ChatRole.User, "Hello")], session, new AgentRunOptions { AllowBackgroundResponses = true });
Print("Scenario 1", agent, session);
// Scenario 1: user:"Hello", assistant:"done", user:"Hello", assistant:"done"
// Scenario 2: streaming stopped after an update carrying a continuation token, then resumed.
agent = new ChatClientAgent(new FakeChatClient(), options: new() { RequirePerServiceCallChatHistoryPersistence = true });
session = await agent.CreateSessionAsync();
ResponseContinuationToken? token = null;
await foreach (var update in agent.RunStreamingAsync([new(ChatRole.User, "Hello")], session))
{
token = update.ContinuationToken;
break;
}
await foreach (var _ in agent.RunStreamingAsync(session, new AgentRunOptions { ContinuationToken = token }))
{
}
Print("Scenario 2", agent, session);
// Scenario 2: user:"Hello", assistant:" rest", user:"Hello", assistant:"partial rest"
static void Print(string title, ChatClientAgent agent, AgentSession session)
{
var messages = ((InMemoryChatHistoryProvider)agent.ChatHistoryProvider!).GetMessages(session);
Console.WriteLine($"{title}: {string.Join(", ", messages.Select(m => $"{m.Role}:\"{m.Text}\""))}");
}
sealed class FakeChatClient : IChatClient
{
private int _streamingCalls;
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
if (this._streamingCalls++ == 0)
{
yield return new ChatResponseUpdate(ChatRole.Assistant, "partial") { MessageId = "m1", ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
}
yield return new ChatResponseUpdate(ChatRole.Assistant, " rest") { MessageId = "m1" };
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
}Package Versions
Microsoft.Agents.AI: 1.26
.NET Version
.NET 10
Operating System
Windows
Regression
Unknown
Additional Context
Both components notify the providers in these runs:
PerServiceCallChatHistoryPersistingChatClientalways callsagent.NotifyProvidersOfNewMessagesAsync(...)after the service call, in bothGetResponseAsyncandGetStreamingResponseAsync, also whenisContinuationOrBackgroundistrue. The inline comment "the decorator is a no-op" only applies to theConversationIdhandling that follows.ChatClientAgentforces end-of-run persistence when a continuation token orAllowBackgroundResponsesis involved (forceEndOfRunPersistence→NotifyProvidersOfNewMessagesAtEndOfRunAsync(..., forceNotify: true)).- When the consumer stops a stream early, the decorator's
finallyblock also persists the input messages (NotifyProvidersOfEarlyExitInputAsync), which are persisted again by the agent when the run is resumed with the continuation token.
The existing tests RunAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync and RunStreamingAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync verify that history loading and the sentinel ConversationId are skipped, but do not verify how many times the providers are notified.
Related: #7288 (proposal to only support per-service-call persistence, which mentions verifying the impact on background responses). Found while working on #9790.
Acknowledgements
- I searched existing issues and did not find a duplicate.
- I personally verified this behavior and the reproduction details are authentic.
- I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
github-actions commentedon Oct 28, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
Repro:
dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs::GetResponseAsync/GetStreamingResponseAsyncunconditionally notify providers at lines 120 and 265 when background responses or continuation tokens already trigger forced end-of-run persistence inChatClientAgent. Minimal repro: enableRequirePerServiceCallChatHistoryPersistence, then either run once withAllowBackgroundResponses=trueor break a stream after receiving a continuation token and resume it; the added tests produce[Hello, done, Hello, done]and[Hello, " rest", Hello, "partial rest"].dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs1.26.01.26.0