diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index c67b40f..88a625b 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -146,7 +146,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner else if (evt is WorkflowOutputEvent outputEvent) { List allMessages = outputEvent.As>() ?? []; - List newMessages = allMessages.Skip(inputMessages.Count).ToList(); + List newMessages = SelectNewOutputMessages(allMessages, inputMessages); completedMessages = ProjectCompletedMessages( command, newMessages, @@ -369,6 +369,70 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return mapped; } + internal static List SelectNewOutputMessages( + IReadOnlyList outputMessages, + IReadOnlyList 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 outputMessages, + IReadOnlyList 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, diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index e3701af..bedf1b8 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -6,6 +6,72 @@ namespace Kopaya.AgentHost.Tests; public sealed class CopilotWorkflowRunnerTests { + [Fact] + public void SelectNewOutputMessages_SkipsFullTranscriptPrefix() + { + List inputMessages = + [ + new(ChatRole.User, "Hello"), + ]; + List outputMessages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there."), + ]; + + IReadOnlyList 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 inputMessages = + [ + new(ChatRole.Assistant, "Earlier answer"), + new(ChatRole.User, "Hello"), + ]; + List outputMessages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there."), + ]; + + IReadOnlyList 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 inputMessages = + [ + new(ChatRole.User, "Hello"), + ]; + List outputMessages = + [ + new(ChatRole.Assistant, "Hi there."), + ]; + + IReadOnlyList 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() {