using System.Text; using Aryx.AgentHost.Contracts; using GitHub.Copilot.SDK; using Microsoft.Extensions.AI; namespace Aryx.AgentHost.Services; internal readonly record struct TranscriptSegment(string MessageId, string AuthorName, string Content) { public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment) => new(segment.MessageId, segment.AuthorName, segment.Content); } internal static class WorkflowTranscriptProjector { public static ChatMessage ToChatMessage(ChatMessageDto message) { ChatMessage mapped = new(message.Role switch { "user" => ChatRole.User, "system" => ChatRole.System, _ => ChatRole.Assistant, }, message.Content); if (!string.IsNullOrWhiteSpace(message.AuthorName)) { mapped.AuthorName = message.AuthorName; } foreach (ChatMessageAttachmentDto attachment in message.Attachments) { mapped.Contents.Add(new AIContent { RawRepresentation = attachment, }); } return mapped; } public static void AttachMessageMode(IList messages, string? messageMode) { if (messages.Count == 0 || string.IsNullOrWhiteSpace(messageMode)) { return; } messages[^1].Contents.Add(new AIContent { RawRepresentation = new CopilotMessageOptionsMetadata(messageMode.Trim()), }); } public static List ProjectCompletedMessages( RunTurnCommandDto command, IReadOnlyList newMessages, IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, AgentIdentity? fallbackAgent = null) { return ProjectCompletedMessagesFromSegments( command, newMessages, segments.Select(TranscriptSegment.FromTuple).ToList(), fallbackAgent); } internal static List ProjectCompletedMessagesFromSegments( RunTurnCommandDto command, IReadOnlyList newMessages, IReadOnlyList segments, AgentIdentity? fallbackAgent = null) { List projectedMessages = []; int fallbackOutputIndex = 0; string createdAt = DateTimeOffset.UtcNow.ToString("O"); List preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments); List remainingSegments = preparedSegments.ToList(); List assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList(); for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++) { ChatMessage message = assistantMessages[messageIndex]; TranscriptSegment? matchedSegment = TryMatchSegment( message, remainingSegments, assistantMessages.Count - messageIndex, command.Pattern, fallbackAgent); string content = ResolveProjectedContent(message, matchedSegment); if (string.IsNullOrWhiteSpace(content)) { continue; } if (matchedSegment.HasValue) { remainingSegments.Remove(matchedSegment.Value); } fallbackOutputIndex++; projectedMessages.Add(CreateProjectedMessage( command, message, matchedSegment, fallbackAgent, createdAt, fallbackOutputIndex, content)); } if (projectedMessages.Count == 0 && preparedSegments.Count > 0) { projectedMessages.AddRange(preparedSegments.Select(segment => CreateProjectedMessageFromSegment(command, segment, createdAt))); } return projectedMessages; } private static ChatMessageDto CreateProjectedMessage( RunTurnCommandDto command, ChatMessage message, TranscriptSegment? matchedSegment, AgentIdentity? fallbackAgent, string createdAt, int fallbackOutputIndex, string content) { return new ChatMessageDto { Id = matchedSegment?.MessageId ?? message.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}", Role = message.Role == ChatRole.System ? "system" : "assistant", AuthorName = ResolveProjectedAuthorName( command.Pattern, message.AuthorName, matchedSegment?.AuthorName, fallbackAgent), Content = content, CreatedAt = createdAt, }; } private static string ResolveProjectedContent( ChatMessage message, TranscriptSegment? matchedSegment) { return FirstNonBlank( message.Text, matchedSegment?.Content, TryGetAssistantMessageContent(message)) ?? string.Empty; } private static string? TryGetAssistantMessageContent(ChatMessage message) { if (TryGetAssistantMessageData(message.RawRepresentation, out AssistantMessageData? assistantMessageData)) { return assistantMessageData?.Content; } foreach (AIContent content in message.Contents) { if (TryGetAssistantMessageData(content.RawRepresentation, out assistantMessageData)) { return assistantMessageData?.Content; } } return null; } private static ChatMessageDto CreateProjectedMessageFromSegment( RunTurnCommandDto command, TranscriptSegment segment, string createdAt) { return new ChatMessageDto { Id = segment.MessageId, Role = "assistant", AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName), Content = segment.Content, CreatedAt = createdAt, }; } private static List PrepareSegmentsForProjection( PatternDefinitionDto pattern, IReadOnlyList 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++) { TranscriptSegment 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 TranscriptSegment? TryMatchSegment( ChatMessage message, IReadOnlyList remainingSegments, int remainingMessageCount, PatternDefinitionDto pattern, AgentIdentity? fallbackAgent) { if (remainingSegments.Count == 0) { return null; } string? messageText = string.IsNullOrWhiteSpace(message.Text) ? null : message.Text; if (messageText is not null) { string resolvedAuthorName = ResolveProjectedAuthorName( pattern, message.AuthorName, fallbackIdentifier: null, fallbackAgent); if (TryFindSegment( remainingSegments, segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal) && string.Equals( AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), resolvedAuthorName, StringComparison.Ordinal), out TranscriptSegment authorMatchedSegment)) { return authorMatchedSegment; } if (TryFindSegment( remainingSegments, segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal), out TranscriptSegment contentMatchedSegment)) { return contentMatchedSegment; } } if (remainingMessageCount == 1) { if (fallbackAgent.HasValue && AgentIdentityResolver.IsGenericAssistantIdentifier(message.AuthorName) && TryFindLastSegment( remainingSegments, segment => string.Equals( AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), fallbackAgent.Value.AgentName, StringComparison.Ordinal), out TranscriptSegment fallbackMatchedSegment)) { return fallbackMatchedSegment; } return remainingSegments[^1]; } return remainingSegments.Count == remainingMessageCount ? remainingSegments[0] : null; } private static bool TryFindSegment( IReadOnlyList segments, Func predicate, out TranscriptSegment matchedSegment) { foreach (TranscriptSegment segment in segments) { if (predicate(segment)) { matchedSegment = segment; return true; } } matchedSegment = default; return false; } private static bool TryFindLastSegment( IReadOnlyList segments, Func predicate, out TranscriptSegment matchedSegment) { for (int index = segments.Count - 1; index >= 0; index--) { TranscriptSegment segment = segments[index]; if (predicate(segment)) { matchedSegment = segment; return true; } } matchedSegment = default; return false; } public 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, string? fallbackIdentifier, AgentIdentity? fallbackAgent) { if (fallbackAgent.HasValue && AgentIdentityResolver.IsGenericAssistantIdentifier(primaryIdentifier)) { return fallbackAgent.Value.AgentName; } if (fallbackAgent.HasValue && string.IsNullOrWhiteSpace(primaryIdentifier) && string.IsNullOrWhiteSpace(fallbackIdentifier)) { return fallbackAgent.Value.AgentName; } if (pattern.Agents.Count == 1 && string.IsNullOrWhiteSpace(primaryIdentifier) && string.IsNullOrWhiteSpace(fallbackIdentifier)) { PatternAgentDefinitionDto singleAgent = pattern.Agents[0]; return AgentIdentityResolver.ResolveDisplayAuthorName(pattern, singleAgent.Id, singleAgent.Name); } return AgentIdentityResolver.ResolveDisplayAuthorName( pattern, primaryIdentifier, fallbackIdentifier); } private static bool TryGetAssistantMessageData( object? rawRepresentation, out AssistantMessageData? assistantMessageData) { switch (rawRepresentation) { case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data.Content): assistantMessageData = assistantMessage.Data; return true; case AssistantMessageData data when !string.IsNullOrWhiteSpace(data.Content): assistantMessageData = data; return true; default: assistantMessageData = null; return false; } } private static string? FirstNonBlank(params string?[] values) { foreach (string? value in values) { if (!string.IsNullOrWhiteSpace(value)) { return value; } } return null; } } internal sealed class StreamingTranscriptBuffer { private readonly List _segments = []; public int Count => _segments.Count; public TranscriptSegment AppendDelta( string messageId, string authorName, string delta) { BufferedTranscriptSegment segment = GetOrCreateSegment(messageId, authorName); segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta)); segment.SetAuthorName(authorName); return segment.ToSnapshot(); } public IReadOnlyList Snapshot() { return _segments.Select(segment => segment.ToSnapshot()).ToList(); } private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName) { BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId); if (existing is not null) { return existing; } BufferedTranscriptSegment created = new(messageId, authorName); _segments.Add(created); return created; } private sealed class BufferedTranscriptSegment { public BufferedTranscriptSegment(string messageId, string authorName) { MessageId = messageId; AuthorName = authorName; } public string MessageId { get; } public string AuthorName { get; private set; } public StringBuilder Content { get; } = new(); public void SetContent(string value) { Content.Clear(); Content.Append(value); } public void SetAuthorName(string value) { AuthorName = value; } public TranscriptSegment ToSnapshot() { return new TranscriptSegment(MessageId, AuthorName, Content.ToString()); } } }