Skip to content

.NET: [Bug]: RequirePerServiceCallChatHistoryPersistence duplicates chat history with background responses and continuation tokens #9793

Open

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

  1. Save the code in the Minimal Reproduction section as repro.cs.
  2. Run it as a file-based app with the .NET 10 SDK: dotnet run repro.cs.
  3. Observe the printed chat history:
    • Scenario 1 (RunAsync with AllowBackgroundResponses = true, completed in a single call): the user message and the assistant response are stored twice.
    • Scenario 2 (RunStreamingAsync stopped after an update carrying a ContinuationToken, 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.

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:

  • PerServiceCallChatHistoryPersistingChatClient always calls agent.NotifyProvidersOfNewMessagesAsync(...) after the service call, in both GetResponseAsync and GetStreamingResponseAsync, also when isContinuationOrBackground is true. The inline comment "the decorator is a no-op" only applies to the ConversationId handling that follows.
  • ChatClientAgent forces end-of-run persistence when a continuation token or AllowBackgroundResponses is involved (forceEndOfRunPersistence → NotifyProvidersOfNewMessagesAtEndOfRunAsync(..., forceNotify: true)).
  • When the consumer stops a stream early, the decorator's finally block 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.

Activity

  1. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Oct 28, 2026
  2. github-actions commented on Oct 28, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    Repro: dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs::GetResponseAsync/GetStreamingResponseAsync unconditionally notify providers at lines 120 and 265 when background responses or continuation tokens already trigger forced end-of-run persistence in ChatClientAgent. Minimal repro: enable RequirePerServiceCallChatHistoryPersistence, then either run once with AllowBackgroundResponses=true or 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"].

    • Failing test: dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs
    • Files examined: dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs, dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs, dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs, dotnet/nuget/nuget-package.props
    • Tests run: RunAsync_DoesNotDuplicateMessages_WhenAllowBackgroundResponsesAsync, RunStreamingAsync_DoesNotDuplicateMessages_WhenResumedWithContinuationTokenAsync, RunAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync, RunStreamingAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync
    • Reported version: 1.26.0
    • Current version: 1.26.0
  3. added
    agentsUsage: [Issues, PRs], Target: Single agent
    and removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

.NETUsage: [Issues, PRs], Target: .NetagentsUsage: [Issues, PRs], Target: Single agentreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions