fix: correct handoff output attribution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 23:55:30 +01:00
co-authored by Copilot
parent 2fc03454c4
commit 7c848113c0
4 changed files with 98 additions and 115 deletions
@@ -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
@@ -30,14 +30,20 @@ internal static class WorkflowTranscriptProjector
AgentIdentity? fallbackAgent = null)
{
List<ChatMessageDto> mapped = [];
int segmentIndex = 0;
int fallbackOutputIndex = 0;
string createdAt = DateTimeOffset.UtcNow.ToString("O");
List<(string MessageId, string AuthorName, string Content)> remainingSegments = segments.ToList();
List<ChatMessage> 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<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
@@ -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",
};
}
}
@@ -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<ChatMessageDto> 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()
{