fix: preserve agent authorship across projections

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 00:10:56 +01:00
co-authored by Copilot
parent 9bfd7837aa
commit 6460fe7173
2 changed files with 189 additions and 15 deletions
@@ -32,7 +32,9 @@ internal static class WorkflowTranscriptProjector
List<ChatMessageDto> 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<ChatMessage> 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<string, ((string MessageId, string AuthorName, string Content) Segment, int LastIndex)> 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<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
@@ -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<ChatMessageDto> 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<ChatMessageDto> 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()
{