mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 12:53:59 +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 = [];
|
List<ChatMessageDto> mapped = [];
|
||||||
int fallbackOutputIndex = 0;
|
int fallbackOutputIndex = 0;
|
||||||
string createdAt = DateTimeOffset.UtcNow.ToString("O");
|
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();
|
List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList();
|
||||||
|
|
||||||
for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++)
|
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,
|
Id = segment.MessageId,
|
||||||
Role = "assistant",
|
Role = "assistant",
|
||||||
@@ -86,6 +88,35 @@ internal static class WorkflowTranscriptProjector
|
|||||||
return mapped;
|
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(
|
private static (string MessageId, string AuthorName, string Content)? TryMatchSegment(
|
||||||
ChatMessage message,
|
ChatMessage message,
|
||||||
IReadOnlyList<(string MessageId, string AuthorName, string Content)> remainingSegments,
|
IReadOnlyList<(string MessageId, string AuthorName, string Content)> remainingSegments,
|
||||||
@@ -107,22 +138,24 @@ internal static class WorkflowTranscriptProjector
|
|||||||
fallbackIdentifier: null,
|
fallbackIdentifier: null,
|
||||||
fallbackAgent);
|
fallbackAgent);
|
||||||
|
|
||||||
(string MessageId, string AuthorName, string Content)? authorMatchedSegment = remainingSegments.FirstOrDefault(
|
if (TryFindSegment(
|
||||||
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)
|
remainingSegments,
|
||||||
&& string.Equals(
|
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)
|
||||||
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
&& string.Equals(
|
||||||
resolvedAuthorName,
|
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
||||||
StringComparison.Ordinal));
|
resolvedAuthorName,
|
||||||
if (authorMatchedSegment.HasValue)
|
StringComparison.Ordinal),
|
||||||
|
out (string MessageId, string AuthorName, string Content) authorMatchedSegment))
|
||||||
{
|
{
|
||||||
return authorMatchedSegment.Value;
|
return authorMatchedSegment;
|
||||||
}
|
}
|
||||||
|
|
||||||
(string MessageId, string AuthorName, string Content)? contentMatchedSegment = remainingSegments.FirstOrDefault(
|
if (TryFindSegment(
|
||||||
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal));
|
remainingSegments,
|
||||||
if (contentMatchedSegment.HasValue)
|
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;
|
: 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(
|
public static List<ChatMessage> SelectNewOutputMessages(
|
||||||
IReadOnlyList<ChatMessage> outputMessages,
|
IReadOnlyList<ChatMessage> outputMessages,
|
||||||
IReadOnlyList<ChatMessage> inputMessages)
|
IReadOnlyList<ChatMessage> inputMessages)
|
||||||
|
|||||||
@@ -244,6 +244,75 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
implementer => Assert.Equal("Implementer", implementer.AuthorName));
|
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]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_PreservesGroupChatConversationHistory()
|
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]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
|
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user