From dd40ebbfcacd94053ddd039c56b973b3626e6103 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sat, 21 Mar 2026 17:37:21 +0100 Subject: [PATCH] fix: finalize streamed agent responses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotWorkflowRunner.cs | 24 +++-- .../CopilotWorkflowRunnerTests.cs | 102 ++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index 353ac64..97b1022 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -138,10 +138,21 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { List allMessages = outputEvent.As>() ?? []; List newMessages = allMessages.Skip(inputMessages.Count).ToList(); - completedMessages = ConvertOutputMessages(command, newMessages, segments); + completedMessages = ProjectCompletedMessages( + command, + newMessages, + segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList()); } } + if (completedMessages.Count == 0 && segments.Count > 0) + { + completedMessages = ProjectCompletedMessages( + command, + [], + segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList()); + } + return completedMessages; } @@ -291,17 +302,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return created; } - private static List ConvertOutputMessages( + internal static List ProjectCompletedMessages( RunTurnCommandDto command, IReadOnlyList newMessages, - IReadOnlyList segments) + IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments) { List mapped = []; int segmentIndex = 0; foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User)) { - StreamingSegment? segment = segmentIndex < segments.Count ? segments[segmentIndex] : null; + (string MessageId, string AuthorName, string Content)? segment = + segmentIndex < segments.Count ? segments[segmentIndex] : null; segmentIndex++; mapped.Add(new ChatMessageDto @@ -312,7 +324,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner command.Pattern, message.AuthorName, segment?.AuthorName), - Content = message.Text ?? segment?.Content.ToString() ?? string.Empty, + Content = message.Text ?? segment?.Content ?? string.Empty, CreatedAt = DateTimeOffset.UtcNow.ToString("O"), }); } @@ -324,7 +336,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner Id = segment.MessageId, Role = "assistant", AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName), - Content = segment.Content.ToString(), + Content = segment.Content, CreatedAt = DateTimeOffset.UtcNow.ToString("O"), })); } diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs new file mode 100644 index 0000000..c17fc65 --- /dev/null +++ b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -0,0 +1,102 @@ +using Kopaya.AgentHost.Contracts; +using Kopaya.AgentHost.Services; +using Microsoft.Extensions.AI; + +namespace Kopaya.AgentHost.Tests; + +public sealed class CopilotWorkflowRunnerTests +{ + [Fact] + public void ProjectCompletedMessages_FallsBackToStreamingSegmentsWhenWorkflowOutputIsMissing() + { + 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-implementer", name: "Implementer"), + ], + }, + }; + + IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + command, + [], + [ + ("msg-1", "Architect", "Architecture reply"), + ("msg-2", "Implementer", "Implementation reply"), + ]); + + Assert.Collection( + messages, + architect => + { + Assert.Equal("msg-1", architect.Id); + Assert.Equal("Architect", architect.AuthorName); + Assert.Equal("Architecture reply", architect.Content); + }, + implementer => + { + Assert.Equal("msg-2", implementer.Id); + Assert.Equal("Implementer", implementer.AuthorName); + Assert.Equal("Implementation reply", implementer.Content); + }); + } + + [Fact] + public void ProjectCompletedMessages_CanonicalizesWorkflowOutputAuthorNames() + { + RunTurnCommandDto command = new() + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-single", + Name = "Single Agent", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent(id: "agent-single-primary", name: "Primary Agent"), + ], + }, + }; + + IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + command, + [ + new ChatMessage(ChatRole.Assistant, "Hello") + { + AuthorName = "assistant", + }, + ], + [ + ("msg-1", "Primary Agent", "Hello"), + ]); + + ChatMessageDto message = Assert.Single(messages); + Assert.Equal("msg-1", message.Id); + Assert.Equal("Primary Agent", message.AuthorName); + Assert.Equal("Hello", message.Content); + } + + private static PatternAgentDefinitionDto CreateAgent(string id, string name) + { + return new PatternAgentDefinitionDto + { + Id = id, + Name = name, + Model = "gpt-5.4", + Instructions = "Help with the request.", + }; + } +}