From 6460fe717367c6955463e05c1f0fa27e7a689660 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 00:10:56 +0100 Subject: [PATCH] fix: preserve agent authorship across projections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/WorkflowTranscriptProjector.cs | 81 +++++++++--- .../CopilotWorkflowRunnerTests.cs | 123 ++++++++++++++++++ 2 files changed, 189 insertions(+), 15 deletions(-) diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs index 752f8bd..ac7a60f 100644 --- a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs @@ -32,7 +32,9 @@ internal static class WorkflowTranscriptProjector List mapped = []; int fallbackOutputIndex = 0; string createdAt = DateTimeOffset.UtcNow.ToString("O"); - List<(string MessageId, string AuthorName, string Content)> remainingSegments = segments.ToList(); + List<(string MessageId, string AuthorName, string Content)> preparedSegments = + PrepareSegmentsForProjection(command.Pattern, segments); + List<(string MessageId, string AuthorName, string Content)> remainingSegments = preparedSegments.ToList(); List assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList(); for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++) @@ -71,9 +73,9 @@ internal static class WorkflowTranscriptProjector }); } - if (mapped.Count == 0 && segments.Count > 0) + if (mapped.Count == 0 && preparedSegments.Count > 0) { - mapped.AddRange(segments.Select(segment => new ChatMessageDto + mapped.AddRange(preparedSegments.Select(segment => new ChatMessageDto { Id = segment.MessageId, Role = "assistant", @@ -86,6 +88,35 @@ internal static class WorkflowTranscriptProjector return mapped; } + private static List<(string MessageId, string AuthorName, string Content)> PrepareSegmentsForProjection( + PatternDefinitionDto pattern, + IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments) + { + if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal) + || segments.Count <= 1) + { + return segments.ToList(); + } + + // Agent Framework concurrent workflows aggregate the last message emitted by each agent. + // Collapse streamed segments to the most recent segment per author, preserving the order + // in which those authors most recently completed so positional fallback stays aligned. + Dictionary latestSegmentByAuthor = + new(StringComparer.Ordinal); + + for (int index = 0; index < segments.Count; index++) + { + (string MessageId, string AuthorName, string Content) segment = segments[index]; + string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName); + latestSegmentByAuthor[authorKey] = (segment, index); + } + + return latestSegmentByAuthor.Values + .OrderBy(entry => entry.LastIndex) + .Select(entry => entry.Segment) + .ToList(); + } + private static (string MessageId, string AuthorName, string Content)? TryMatchSegment( ChatMessage message, IReadOnlyList<(string MessageId, string AuthorName, string Content)> remainingSegments, @@ -107,22 +138,24 @@ internal static class WorkflowTranscriptProjector fallbackIdentifier: null, fallbackAgent); - (string MessageId, string AuthorName, string Content)? authorMatchedSegment = remainingSegments.FirstOrDefault( - segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal) - && string.Equals( - AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), - resolvedAuthorName, - StringComparison.Ordinal)); - if (authorMatchedSegment.HasValue) + if (TryFindSegment( + remainingSegments, + segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal) + && string.Equals( + AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), + resolvedAuthorName, + StringComparison.Ordinal), + out (string MessageId, string AuthorName, string Content) authorMatchedSegment)) { - return authorMatchedSegment.Value; + return authorMatchedSegment; } - (string MessageId, string AuthorName, string Content)? contentMatchedSegment = remainingSegments.FirstOrDefault( - segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)); - if (contentMatchedSegment.HasValue) + if (TryFindSegment( + remainingSegments, + segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal), + out (string MessageId, string AuthorName, string Content) contentMatchedSegment)) { - return contentMatchedSegment.Value; + return contentMatchedSegment; } } @@ -131,6 +164,24 @@ internal static class WorkflowTranscriptProjector : null; } + private static bool TryFindSegment( + IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, + Func<(string MessageId, string AuthorName, string Content), bool> predicate, + out (string MessageId, string AuthorName, string Content) matchedSegment) + { + foreach ((string MessageId, string AuthorName, string Content) segment in segments) + { + if (predicate(segment)) + { + matchedSegment = segment; + return true; + } + } + + matchedSegment = default; + return false; + } + public static List SelectNewOutputMessages( IReadOnlyList outputMessages, IReadOnlyList inputMessages) diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 3f7cd2a..763133b 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -244,6 +244,75 @@ public sealed class CopilotWorkflowRunnerTests implementer => Assert.Equal("Implementer", implementer.AuthorName)); } + [Fact] + public void ProjectCompletedMessages_ConcurrentUsesLastStreamedMessagePerAgentForGenericOutput() + { + RunTurnCommandDto command = new() + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-concurrent", + Name = "Concurrent Brainstorm", + Mode = "concurrent", + Availability = "available", + Agents = + [ + CreateAgent(id: "agent-concurrent-architect", name: "Architect"), + CreateAgent(id: "agent-concurrent-product", name: "Product"), + CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"), + ], + }, + }; + + IReadOnlyList messages = WorkflowTranscriptProjector.ProjectCompletedMessages( + command, + [ + new ChatMessage(ChatRole.Assistant, "Architecture concerns with cleaner wording.") + { + AuthorName = "assistant", + }, + new ChatMessage(ChatRole.Assistant, "Product trade-offs with cleaned bullets.") + { + AuthorName = "assistant", + }, + new ChatMessage(ChatRole.Assistant, "Implementation details with cleaned formatting.") + { + AuthorName = "assistant", + }, + ], + [ + ("msg-arch-1", "Architect", "Architecture concerns."), + ("msg-prod-1", "Product", "Product trade-offs."), + ("msg-impl-1", "Implementer", "Implementation details."), + ("msg-arch-2", "Architect", "Architecture concerns with extra draft spacing."), + ("msg-prod-2", "Product", "Product trade-offs with extra draft bullets."), + ("msg-impl-2", "Implementer", "Implementation details with extra draft formatting."), + ]); + + Assert.Collection( + messages, + architect => + { + Assert.Equal("msg-arch-2", architect.Id); + Assert.Equal("Architect", architect.AuthorName); + Assert.Equal("Architecture concerns with cleaner wording.", architect.Content); + }, + product => + { + Assert.Equal("msg-prod-2", product.Id); + Assert.Equal("Product", product.AuthorName); + Assert.Equal("Product trade-offs with cleaned bullets.", product.Content); + }, + implementer => + { + Assert.Equal("msg-impl-2", implementer.Id); + Assert.Equal("Implementer", implementer.AuthorName); + Assert.Equal("Implementation details with cleaned formatting.", implementer.Content); + }); + } + [Fact] public void ProjectCompletedMessages_PreservesGroupChatConversationHistory() { @@ -293,6 +362,60 @@ public sealed class CopilotWorkflowRunnerTests }); } + [Fact] + public void ProjectCompletedMessages_FallsBackToPositionWhenOutputTextDiffers() + { + RunTurnCommandDto command = new() + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-group-chat", + Name = "Collaborative Group Chat", + Mode = "group-chat", + Availability = "available", + Agents = + [ + CreateAgent(id: "agent-group-writer", name: "Writer"), + CreateAgent(id: "agent-group-reviewer", name: "Reviewer"), + ], + }, + }; + + IReadOnlyList messages = WorkflowTranscriptProjector.ProjectCompletedMessages( + command, + [ + new ChatMessage(ChatRole.Assistant, "Initial draft with cleaner wording.") + { + AuthorName = "assistant", + }, + new ChatMessage(ChatRole.Assistant, "Review feedback with cleaner wording.") + { + AuthorName = "assistant", + }, + ], + [ + ("msg-1", "Writer", "Initial draft with extra draft wording."), + ("msg-2", "Reviewer", "Review feedback with extra draft wording."), + ]); + + Assert.Collection( + messages, + writer => + { + Assert.Equal("msg-1", writer.Id); + Assert.Equal("Writer", writer.AuthorName); + Assert.Equal("Initial draft with cleaner wording.", writer.Content); + }, + reviewer => + { + Assert.Equal("msg-2", reviewer.Id); + Assert.Equal("Reviewer", reviewer.AuthorName); + Assert.Equal("Review feedback with cleaner wording.", reviewer.Content); + }); + } + [Fact] public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput() {