mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
This commit is contained in:
@@ -104,15 +104,17 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
{
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
{
|
||||
// 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);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
{
|
||||
// Apply pre-write reduction strategy if configured
|
||||
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
|
||||
|
||||
+62
-5
@@ -243,7 +243,8 @@ public class InMemoryChatHistoryProviderTests
|
||||
var session = CreateMockSession();
|
||||
|
||||
// Arrange
|
||||
var originalMessages = new List<ChatMessage>
|
||||
// Existing messages in state from a previous turn.
|
||||
var existingMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
@@ -253,22 +254,78 @@ public class InMemoryChatHistoryProviderTests
|
||||
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<IChatReducer>();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()))
|
||||
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(existingMessages)), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(reducedMessages);
|
||||
|
||||
var provider = new InMemoryChatHistoryProvider(new() { ChatReducer = reducerMock.Object, ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded });
|
||||
provider.SetMessages(session, new List<ChatMessage>(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<List<ChatMessage>>(x => x.SequenceEqual(existingMessages)), It.IsAny<CancellationToken>()), 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<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()), 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<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Previous question"),
|
||||
new(ChatRole.Assistant, "Previous answer")
|
||||
};
|
||||
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.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<ChatMessage>(existingMessages));
|
||||
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "What is the weather in Taggia?")
|
||||
};
|
||||
var responseMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, [new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["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<FunctionCallContent>().Any(), "Function call message should be preserved");
|
||||
Assert.True(messages[2].Contents.OfType<FunctionResultContent>().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]
|
||||
|
||||
Reference in New Issue
Block a user