fix: preserve single-chat final messages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-22 10:43:59 +01:00
co-authored by Copilot
parent 4e4f6ebedb
commit 9799a959d6
2 changed files with 131 additions and 1 deletions
@@ -146,7 +146,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
else if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
List<ChatMessage> newMessages = allMessages.Skip(inputMessages.Count).ToList();
List<ChatMessage> newMessages = SelectNewOutputMessages(allMessages, inputMessages);
completedMessages = ProjectCompletedMessages(
command,
newMessages,
@@ -369,6 +369,70 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return mapped;
}
internal static List<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
if (outputMessages.Count == 0)
{
return [];
}
if (inputMessages.Count == 0)
{
return outputMessages.ToList();
}
int overlapLength = FindOutputInputOverlapLength(outputMessages, inputMessages);
return outputMessages.Skip(overlapLength).ToList();
}
private static int FindOutputInputOverlapLength(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
int maxOverlap = Math.Min(outputMessages.Count, inputMessages.Count);
for (int overlapLength = maxOverlap; overlapLength > 0; overlapLength--)
{
int inputStart = inputMessages.Count - overlapLength;
bool matches = true;
for (int index = 0; index < overlapLength; index++)
{
if (!ChatMessagesMatch(inputMessages[inputStart + index], outputMessages[index]))
{
matches = false;
break;
}
}
if (matches)
{
return overlapLength;
}
}
return 0;
}
private static bool ChatMessagesMatch(ChatMessage inputMessage, ChatMessage outputMessage)
{
if (inputMessage.Role != outputMessage.Role)
{
return false;
}
if (!string.Equals(inputMessage.Text, outputMessage.Text, StringComparison.Ordinal))
{
return false;
}
return string.IsNullOrWhiteSpace(inputMessage.AuthorName)
|| string.IsNullOrWhiteSpace(outputMessage.AuthorName)
|| string.Equals(inputMessage.AuthorName, outputMessage.AuthorName, StringComparison.Ordinal);
}
private static string ResolveProjectedAuthorName(
PatternDefinitionDto pattern,
string? primaryIdentifier,
@@ -6,6 +6,72 @@ namespace Kopaya.AgentHost.Tests;
public sealed class CopilotWorkflowRunnerTests
{
[Fact]
public void SelectNewOutputMessages_SkipsFullTranscriptPrefix()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void SelectNewOutputMessages_SkipsOnlyTheLatestInputOverlap()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.Assistant, "Earlier answer"),
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void SelectNewOutputMessages_PreservesAssistantOnlyOutput()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void ProjectCompletedMessages_FallsBackToStreamingSegmentsWhenWorkflowOutputIsMissing()
{