diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs index ac7a60f..1504f71 100644 --- a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs @@ -159,6 +159,24 @@ internal static class WorkflowTranscriptProjector } } + 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 (string MessageId, string AuthorName, string Content) fallbackMatchedSegment)) + { + return fallbackMatchedSegment; + } + + return remainingSegments[^1]; + } + return remainingSegments.Count == remainingMessageCount ? remainingSegments[0] : null; @@ -182,6 +200,25 @@ internal static class WorkflowTranscriptProjector return false; } + private static bool TryFindLastSegment( + 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) + { + for (int index = segments.Count - 1; index >= 0; index--) + { + (string MessageId, string AuthorName, string Content) segment = segments[index]; + 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 763133b..cc23cce 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -494,6 +494,47 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("Done — GoogleClient.cs now contains the requested class.", message.Content); } + [Fact] + public void ProjectCompletedMessages_UsesFallbackAgentForSingleGenericAssistantOutputWithMultipleSegments() + { + RunTurnCommandDto command = new() + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-handoff", + Name = "Handoff Support Flow", + Mode = "handoff", + Availability = "available", + Agents = + [ + CreateAgent(id: "agent-handoff-triage", name: "Triage"), + CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"), + ], + }, + }; + + IReadOnlyList messages = WorkflowTranscriptProjector.ProjectCompletedMessages( + command, + [ + new ChatMessage(ChatRole.Assistant, "Done — GoogleClient.cs now contains the requested class with cleaned formatting.") + { + AuthorName = "assistant", + }, + ], + [ + ("msg-1", "Triage", "I'll hand this to a coding specialist."), + ("msg-2", "Runtime Specialist", "Done — GoogleClient.cs now contains the requested class with draft formatting."), + ], + new AgentIdentity("agent-handoff-runtime", "Runtime Specialist")); + + ChatMessageDto message = Assert.Single(messages); + Assert.Equal("msg-2", message.Id); + Assert.Equal("Runtime Specialist", message.AuthorName); + Assert.Equal("Done — GoogleClient.cs now contains the requested class with cleaned formatting.", message.Content); + } + [Fact] public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages() {