fix: preserve handoff agent identity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 00:02:46 +01:00
co-authored by Copilot
parent 7c848113c0
commit 9bfd7837aa
4 changed files with 118 additions and 5 deletions
@@ -22,6 +22,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
public static async Task<CopilotAgentBundle> CreateAsync(
RunTurnCommandDto command,
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
CancellationToken cancellationToken)
{
List<IAsyncDisposable> disposables = [];
@@ -53,6 +54,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
},
WorkingDirectory = command.ProjectPath,
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
Streaming = true,
};
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using Eryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Services;
@@ -8,6 +9,7 @@ internal sealed class CopilotTurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
@@ -54,6 +56,34 @@ internal sealed class CopilotTurnExecutionState
}
}
public void ObserveSessionEvent(PatternAgentDefinitionDto agentDefinition, SessionEvent sessionEvent)
{
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
_command.Pattern,
agentDefinition.Id,
agentDefinition.Name);
switch (sessionEvent)
{
case AssistantMessageDeltaEvent messageDelta when !string.IsNullOrWhiteSpace(messageDelta.Data?.MessageId):
RecordObservedAgentForMessage(agent, messageDelta.Data!.MessageId);
break;
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
break;
case AssistantReasoningDeltaEvent:
ActiveAgent = agent;
break;
}
}
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{
agent = default;
return !string.IsNullOrWhiteSpace(messageId)
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
}
public string CreateMessageId(string? messageId)
{
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
@@ -76,6 +106,12 @@ internal sealed class CopilotTurnExecutionState
}
}
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
{
ActiveAgent = agent;
_observedAgentsByMessageId[messageId] = agent;
}
public void UpdateCompletedMessages(
IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages)
@@ -38,6 +38,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
state.ToolNamesByCallId,
onApproval,
cancellationToken),
(agent, sessionEvent) => state.ObserveSessionEvent(agent, sessionEvent),
cancellationToken);
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
@@ -136,7 +137,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
AgentIdentity? updateAgent = null;
string authorName = update.ExecutorId;
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
if (state.TryResolveObservedAgentForMessage(update.Update.MessageId, out AgentIdentity observedMessageAgent))
{
updateAgent = observedMessageAgent;
authorName = observedMessageAgent.AgentName;
}
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Pattern,
update.ExecutorId,
state.ActiveAgent,
@@ -451,11 +451,59 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("A plus", first.Content);
},
second =>
{
Assert.Equal("msg-2", second.MessageId);
Assert.Equal("Implementer", second.AuthorName);
Assert.Equal("B", second.Content);
});
}
[Fact]
public void ObserveSessionEvent_TracksObservedAgentByMessageId()
{
CopilotTurnExecutionState state = new(CreateHandoffCommand());
SessionEvent sessionEvent = SessionEvent.FromJson(
"""
{
Assert.Equal("msg-2", second.MessageId);
Assert.Equal("Implementer", second.AuthorName);
Assert.Equal("B", second.Content);
});
"type": "assistant.message_delta",
"data": {
"messageId": "msg-7",
"deltaContent": "Done."
},
"id": "11111111-1111-1111-1111-111111111111",
"timestamp": "2026-03-24T00:00:00Z"
}
""");
state.ObserveSessionEvent(CreateAgent("agent-handoff-ux", "UX Specialist"), sessionEvent);
Assert.True(state.TryResolveObservedAgentForMessage("msg-7", out AgentIdentity observedAgent));
Assert.Equal("agent-handoff-ux", observedAgent.AgentId);
Assert.Equal("UX Specialist", observedAgent.AgentName);
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
}
[Fact]
public void ObserveSessionEvent_UsesReasoningDeltasToTrackActiveAgent()
{
CopilotTurnExecutionState state = new(CreateHandoffCommand());
SessionEvent sessionEvent = SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning_delta",
"data": {
"reasoningId": "reasoning-1",
"deltaContent": "Planning."
},
"id": "22222222-2222-2222-2222-222222222222",
"timestamp": "2026-03-24T00:00:00Z"
}
""");
state.ObserveSessionEvent(CreateAgent("agent-handoff-ux", "UX Specialist"), sessionEvent);
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName);
}
[Fact]
@@ -770,6 +818,27 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static RunTurnCommandDto CreateHandoffCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-handoff",
Name = "Handoff Flow",
Mode = "handoff",
Availability = "available",
Agents =
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateAgent("agent-handoff-ux", "UX Specialist"),
],
},
};
}
private static RunTurnCommandDto CreateApprovalCommand()
{
return new RunTurnCommandDto