From 7c848113c005cbe39c5e17821184dab9245daae3 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 23:55:30 +0100 Subject: [PATCH] fix: correct handoff output attribution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotAgentBundle.cs | 21 +---- .../Services/WorkflowTranscriptProjector.cs | 61 +++++++++++-- .../CopilotAgentBundleTests.cs | 90 ------------------- .../CopilotWorkflowRunnerTests.cs | 41 +++++++++ 4 files changed, 98 insertions(+), 115 deletions(-) delete mode 100644 sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs index 8f75294..b555597 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs @@ -56,7 +56,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable Streaming = true, }; - if (ShouldDisableSessionTools(command.Pattern, definition, command.WorkspaceKind)) + if (isScratchpad) { sessionConfig.AvailableTools = []; } @@ -90,25 +90,6 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable return bundle; } - internal static bool ShouldDisableSessionTools( - PatternDefinitionDto pattern, - PatternAgentDefinitionDto definition, - string workspaceKind) - { - if (string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern); - return string.Equals(definition.Id, topology.EntryAgentId, StringComparison.OrdinalIgnoreCase); - } - public Workflow BuildWorkflow(PatternDefinitionDto pattern) { return pattern.Mode switch diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs index 1ab3dd6..752f8bd 100644 --- a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs @@ -30,14 +30,20 @@ internal static class WorkflowTranscriptProjector AgentIdentity? fallbackAgent = null) { List mapped = []; - int segmentIndex = 0; int fallbackOutputIndex = 0; string createdAt = DateTimeOffset.UtcNow.ToString("O"); + List<(string MessageId, string AuthorName, string Content)> remainingSegments = segments.ToList(); + List assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList(); - foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User)) + for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++) { - (string MessageId, string AuthorName, string Content)? segment = - segmentIndex < segments.Count ? segments[segmentIndex] : null; + ChatMessage message = assistantMessages[messageIndex]; + (string MessageId, string AuthorName, string Content)? segment = TryMatchSegment( + message, + remainingSegments, + assistantMessages.Count - messageIndex, + command.Pattern, + fallbackAgent); string content = message.Text ?? segment?.Content ?? string.Empty; if (string.IsNullOrWhiteSpace(content)) { @@ -46,7 +52,7 @@ internal static class WorkflowTranscriptProjector if (segment.HasValue) { - segmentIndex++; + remainingSegments.Remove(segment.Value); } fallbackOutputIndex++; @@ -80,6 +86,51 @@ internal static class WorkflowTranscriptProjector return mapped; } + private static (string MessageId, string AuthorName, string Content)? TryMatchSegment( + ChatMessage message, + IReadOnlyList<(string MessageId, string AuthorName, string Content)> 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); + + (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) + { + return authorMatchedSegment.Value; + } + + (string MessageId, string AuthorName, string Content)? contentMatchedSegment = remainingSegments.FirstOrDefault( + segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)); + if (contentMatchedSegment.HasValue) + { + return contentMatchedSegment.Value; + } + } + + return remainingSegments.Count == remainingMessageCount + ? remainingSegments[0] + : null; + } + public static List SelectNewOutputMessages( IReadOnlyList outputMessages, IReadOnlyList inputMessages) diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs deleted file mode 100644 index 2758381..0000000 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Eryx.AgentHost.Contracts; -using Eryx.AgentHost.Services; - -namespace Eryx.AgentHost.Tests; - -public sealed class CopilotAgentBundleTests -{ - [Fact] - public void ShouldDisableSessionTools_DisablesScratchpadAgents() - { - PatternDefinitionDto pattern = CreatePattern( - mode: "sequential", - CreateAgent("agent-primary", "Primary")); - - bool disabled = CopilotAgentBundle.ShouldDisableSessionTools( - pattern, - pattern.Agents[0], - workspaceKind: "scratchpad"); - - Assert.True(disabled); - } - - [Fact] - public void ShouldDisableSessionTools_DisablesOnlyHandoffEntryAgent() - { - PatternDefinitionDto pattern = CreatePattern( - mode: "handoff", - CreateAgent("agent-handoff-triage", "Triage"), - CreateAgent("agent-handoff-runtime", "Runtime Specialist")); - - bool triageDisabled = CopilotAgentBundle.ShouldDisableSessionTools( - pattern, - pattern.Agents[0], - workspaceKind: "project"); - bool specialistDisabled = CopilotAgentBundle.ShouldDisableSessionTools( - pattern, - pattern.Agents[1], - workspaceKind: "project"); - - Assert.True(triageDisabled); - Assert.False(specialistDisabled); - } - - [Fact] - public void ShouldDisableSessionTools_LeavesNonHandoffProjectAgentsEnabled() - { - PatternDefinitionDto pattern = CreatePattern( - mode: "sequential", - CreateAgent("agent-analyst", "Analyst"), - CreateAgent("agent-builder", "Builder")); - - bool disabled = CopilotAgentBundle.ShouldDisableSessionTools( - pattern, - pattern.Agents[0], - workspaceKind: "project"); - - Assert.False(disabled); - } - - private static PatternDefinitionDto CreatePattern(string mode, params PatternAgentDefinitionDto[] agents) - { - return new PatternDefinitionDto - { - Id = $"pattern-{mode}", - Name = mode, - Mode = mode, - Availability = "available", - Agents = agents, - Graph = PatternGraphResolver.CreateDefault(new PatternDefinitionDto - { - Id = $"pattern-{mode}", - Name = mode, - Mode = mode, - Availability = "available", - Agents = agents, - }), - }; - } - - private static PatternAgentDefinitionDto CreateAgent(string id, string name) - { - return new PatternAgentDefinitionDto - { - Id = id, - Name = name, - Instructions = $"You are {name}.", - Model = "gpt-5.4", - }; - } -} diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 673ac99..5ac77bd 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -330,6 +330,47 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("The button is in place.", message.Content); } + [Fact] + public void ProjectCompletedMessages_PrefersContentMatchedStreamingSegmentOverPosition() + { + 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.") + { + 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."), + ], + 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.", message.Content); + } + [Fact] public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages() {