From 401f16071942f202a01bb75b46be5f7df1516073 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:08:23 +0000 Subject: [PATCH 1/2] Initial plan From 071456384eb949326a014b3b8b3837b2a484d797 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:34:22 +0000 Subject: [PATCH 2/2] Fix: preserve function call/tool result messages in session when using ChatReducer with AfterMessageAdded trigger Co-authored-by: crickman <66376200+crickman@users.noreply.github.com> --- .../InMemoryChatHistoryProvider.cs | 12 ++-- .../InMemoryChatHistoryProviderTests.cs | 67 +++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 8db6666c37..d743440fbc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -104,15 +104,17 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, { State state = this._sessionState.GetOrInitializeState(context.Session); - // Add request and response messages to the provider - var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []); - state.Messages.AddRange(allNewMessages); - if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null) { - // Apply pre-write reduction strategy if configured + // Reduce existing messages before adding new messages from the current turn. + // This ensures messages from the current turn (including function calls and tool results) + // are always preserved in full and are not immediately reduced. await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); } + + // Add request and response messages to the provider + var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []); + state.Messages.AddRange(allNewMessages); } private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs index 94beb08bdf..27a5e980d6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs @@ -243,7 +243,8 @@ public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_InvokesReducerA var session = CreateMockSession(); // Arrange - var originalMessages = new List + // Existing messages in state from a previous turn. + var existingMessages = new List { new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi there!") @@ -253,22 +254,78 @@ public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_InvokesReducerA new(ChatRole.User, "Reduced") }; + // New messages being added in the current turn. + var newRequestMessage = new ChatMessage(ChatRole.User, "New message"); + var newResponseMessage = new ChatMessage(ChatRole.Assistant, "New response"); + var reducerMock = new Mock(); reducerMock - .Setup(r => r.ReduceAsync(It.Is>(x => x.SequenceEqual(originalMessages)), It.IsAny())) + .Setup(r => r.ReduceAsync(It.Is>(x => x.SequenceEqual(existingMessages)), It.IsAny())) .ReturnsAsync(reducedMessages); var provider = new InMemoryChatHistoryProvider(new() { ChatReducer = reducerMock.Object, ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded }); + provider.SetMessages(session, new List(existingMessages)); // Act - var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, originalMessages, []); + var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [newRequestMessage], [newResponseMessage]); await provider.InvokedAsync(context, CancellationToken.None); // Assert + // The reducer is called on existing messages before the new ones are added. + reducerMock.Verify(r => r.ReduceAsync(It.Is>(x => x.SequenceEqual(existingMessages)), It.IsAny()), Times.Once); + + // Final state: reduced existing messages + new current-turn messages (preserved in full). var messages = provider.GetMessages(session); - Assert.Single(messages); + Assert.Equal(3, messages.Count); Assert.Equal("Reduced", messages[0].Text); - reducerMock.Verify(r => r.ReduceAsync(It.Is>(x => x.SequenceEqual(originalMessages)), It.IsAny()), Times.Once); + Assert.Equal("New message", messages[1].Text); + Assert.Equal("New response", messages[2].Text); + } + + [Fact] + public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_PreservesCurrentTurnFunctionCallsAsync() + { + var session = CreateMockSession(); + + // Arrange - verify that function call and tool result messages from the current turn are preserved + // even when a reducer is configured with AfterMessageAdded trigger. The reducer should only + // be applied to existing (previous-turn) messages, not to the new messages being added. + var existingMessages = new List + { + new(ChatRole.User, "Previous question"), + new(ChatRole.Assistant, "Previous answer") + }; + + var reducerMock = new Mock(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); // Simulates an aggressive reducer that clears all messages it receives + + var provider = new InMemoryChatHistoryProvider(new() { ChatReducer = reducerMock.Object, ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded }); + provider.SetMessages(session, new List(existingMessages)); + + var requestMessages = new List + { + new(ChatRole.User, "What is the weather in Taggia?") + }; + var responseMessages = new List + { + new(ChatRole.Assistant, [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["location"] = "Taggia" })]), + new(ChatRole.Tool, [new FunctionResultContent("call1", "Cloudy with a high of 15°C")]), + new(ChatRole.Assistant, "The weather in Taggia is cloudy with a high of 15°C.") + }; + + // Act + var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, requestMessages, responseMessages); + await provider.InvokedAsync(context, CancellationToken.None); + + // Assert - all current-turn messages (including function call and tool result) are preserved + var messages = provider.GetMessages(session); + Assert.Equal(4, messages.Count); + Assert.Equal("What is the weather in Taggia?", messages[0].Text); + Assert.True(messages[1].Contents.OfType().Any(), "Function call message should be preserved"); + Assert.True(messages[2].Contents.OfType().Any(), "Tool result message should be preserved"); + Assert.Equal("The weather in Taggia is cloudy with a high of 15°C.", messages[3].Text); } [Fact]