From 9bfd7837aa670b02a5a4e51abf5aed6a9e3a5e98 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 00:02:46 +0100 Subject: [PATCH] fix: preserve handoff agent identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotAgentBundle.cs | 2 + .../Services/CopilotTurnExecutionState.cs | 36 +++++++++ .../Services/CopilotWorkflowRunner.cs | 8 +- .../CopilotWorkflowRunnerTests.cs | 77 ++++++++++++++++++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs index b555597..d594760 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs @@ -22,6 +22,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable public static async Task CreateAsync( RunTurnCommandDto command, Func> onPermissionRequest, + Action? onSessionEvent, CancellationToken cancellationToken) { List 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, }; diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs index 8b76753..cb5724e 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -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 _startedAgents = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _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 allMessages, IReadOnlyList inputMessages) diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs index 7130e04..2836f71 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -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 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, diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 5ac77bd..3f7cd2a 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -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