mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 10:28:43 +02:00
fix: preserve agent authorship across projections
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user