diff --git a/BACKEND_UI_CHANGES.md b/BACKEND_UI_CHANGES.md index 3658c6a..b22d0de 100644 --- a/BACKEND_UI_CHANGES.md +++ b/BACKEND_UI_CHANGES.md @@ -61,7 +61,7 @@ The .NET sidecar emits `agent-activity` events when the workflow stream exposes In practice, `thinking` and `completed` are driven by executor lifecycle events, while `tool-calling` and `handoff` are emitted when request-info payloads expose recognizable tool-call or handoff targets. -One important runtime nuance is that MAF / Copilot executor identifiers are not always the raw configured agent IDs. Real streams can emit composite values such as `Architect_agent_concurrent_architect` or generic names such as `assistant`. The sidecar therefore normalizes these runtime identifiers back to the configured pattern agents before emitting `agent-activity` events or choosing the visible author name. +One important runtime nuance is that MAF / Copilot executor identifiers are not always the raw configured agent IDs. Real streams can emit composite values such as `Architect_agent_concurrent_architect` or generic names such as `assistant`. The sidecar therefore normalizes these runtime identifiers back to the configured pattern agents before emitting `agent-activity` events or choosing the visible author name. This is especially important in handoff flows, where the target agent can continue emitting generic `assistant` updates after control transfer; the sidecar now carries the handed-off identity forward so those updates and final messages still belong to the real target agent. ### .NET sidecar changes diff --git a/sidecar/src/Kopaya.AgentHost/Services/AgentIdentityResolver.cs b/sidecar/src/Kopaya.AgentHost/Services/AgentIdentityResolver.cs index c21234a..f537315 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/AgentIdentityResolver.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/AgentIdentityResolver.cs @@ -35,6 +35,27 @@ internal static class AgentIdentityResolver return true; } + public static bool TryResolveObservedAgentIdentity( + PatternDefinitionDto pattern, + string? agentIdentifier, + AgentIdentity? fallbackAgent, + out AgentIdentity agent) + { + if (TryResolveKnownAgentIdentity(pattern, agentIdentifier, out agent)) + { + return true; + } + + if (fallbackAgent.HasValue && IsGenericAssistantIdentifier(agentIdentifier)) + { + agent = fallbackAgent.Value; + return true; + } + + agent = default; + return false; + } + public static AgentIdentity ResolveAgentIdentity( PatternDefinitionDto pattern, string? agentId, @@ -147,7 +168,7 @@ internal static class AgentIdentityResolver && normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal); } - private static bool IsGenericAssistantIdentifier(string? candidate) + internal static bool IsGenericAssistantIdentifier(string? candidate) { return string.Equals( NormalizeComparisonKey(candidate), diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index 97b1022..95b0820 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -77,6 +77,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner if (activity is not null) { + if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(activity.AgentId) + && !string.IsNullOrWhiteSpace(activity.AgentName)) + { + activeAgent = new AgentIdentity(activity.AgentId, activity.AgentName); + } + await onActivity(activity).ConfigureAwait(false); } } @@ -84,9 +91,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { AgentIdentity? updateAgent = null; string authorName = update.ExecutorId; - if (AgentIdentityResolver.TryResolveKnownAgentIdentity( + if (AgentIdentityResolver.TryResolveObservedAgentIdentity( command.Pattern, update.ExecutorId, + activeAgent, out AgentIdentity resolvedUpdateAgent)) { updateAgent = resolvedUpdateAgent; @@ -123,9 +131,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner }).ConfigureAwait(false); } else if (evt is ExecutorCompletedEvent completed - && AgentIdentityResolver.TryResolveKnownAgentIdentity( + && AgentIdentityResolver.TryResolveObservedAgentIdentity( command.Pattern, completed.ExecutorId, + activeAgent, out AgentIdentity completedAgent)) { if (activeAgent.HasValue @@ -141,7 +150,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner completedMessages = ProjectCompletedMessages( command, newMessages, - segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList()); + segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList(), + activeAgent); } } @@ -150,7 +160,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner completedMessages = ProjectCompletedMessages( command, [], - segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList()); + segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList(), + activeAgent); } return completedMessages; @@ -305,26 +316,40 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner internal static List ProjectCompletedMessages( RunTurnCommandDto command, IReadOnlyList newMessages, - IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments) + IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, + AgentIdentity? fallbackAgent = null) { List mapped = []; int segmentIndex = 0; + int fallbackOutputIndex = 0; foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User)) { (string MessageId, string AuthorName, string Content)? segment = segmentIndex < segments.Count ? segments[segmentIndex] : null; - segmentIndex++; + string content = message.Text ?? segment?.Content ?? string.Empty; + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + + if (segment.HasValue) + { + segmentIndex++; + } + + fallbackOutputIndex++; mapped.Add(new ChatMessageDto { - Id = segment?.MessageId ?? $"{command.RequestId}-final-{segmentIndex}", + Id = segment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}", Role = message.Role == ChatRole.System ? "system" : "assistant", - AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName( + AuthorName = ResolveProjectedAuthorName( command.Pattern, message.AuthorName, - segment?.AuthorName), - Content = message.Text ?? segment?.Content ?? string.Empty, + segment?.AuthorName, + fallbackAgent), + Content = content, CreatedAt = DateTimeOffset.UtcNow.ToString("O"), }); } @@ -344,6 +369,23 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return mapped; } + private static string ResolveProjectedAuthorName( + PatternDefinitionDto pattern, + string? primaryIdentifier, + string? fallbackIdentifier, + AgentIdentity? fallbackAgent) + { + if (fallbackAgent.HasValue && AgentIdentityResolver.IsGenericAssistantIdentifier(primaryIdentifier)) + { + return fallbackAgent.Value.AgentName; + } + + return AgentIdentityResolver.ResolveDisplayAuthorName( + pattern, + primaryIdentifier, + fallbackIdentifier); + } + private static ChatMessage ToChatMessage(ChatMessageDto message) { ChatMessage mapped = new(message.Role switch diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/AgentIdentityResolverTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/AgentIdentityResolverTests.cs index f154c4a..7669d97 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/AgentIdentityResolverTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/AgentIdentityResolverTests.cs @@ -95,6 +95,26 @@ public sealed class AgentIdentityResolverTests Assert.Equal("Implementer", authorName); } + [Fact] + public void TryResolveObservedAgentIdentity_UsesFallbackAgentForGenericAssistant() + { + PatternDefinitionDto pattern = CreatePattern( + [ + CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"), + CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"), + ]); + + bool resolved = AgentIdentityResolver.TryResolveObservedAgentIdentity( + pattern, + "assistant", + new AgentIdentity("agent-handoff-ux", "UX Specialist"), + out AgentIdentity agent); + + Assert.True(resolved); + Assert.Equal("agent-handoff-ux", agent.AgentId); + Assert.Equal("UX Specialist", agent.AgentName); + } + private static PatternDefinitionDto CreatePattern( IReadOnlyList agents, string mode = "concurrent") diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index c17fc65..e3701af 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -89,6 +89,84 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("Hello", message.Content); } + [Fact] + public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput() + { + 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-ux", name: "UX Specialist"), + ], + }, + }; + + IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + command, + [ + new ChatMessage(ChatRole.Assistant, "The button is in place.") + { + AuthorName = "assistant", + }, + ], + [], + new AgentIdentity("agent-handoff-ux", "UX Specialist")); + + ChatMessageDto message = Assert.Single(messages); + Assert.Equal("UX Specialist", message.AuthorName); + Assert.Equal("The button is in place.", message.Content); + } + + [Fact] + public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages() + { + 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-ux", name: "UX Specialist"), + ], + }, + }; + + IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + command, + [ + new ChatMessage(ChatRole.Assistant, string.Empty) + { + AuthorName = "assistant", + }, + new ChatMessage(ChatRole.Assistant, "Real content") + { + AuthorName = "assistant", + }, + ], + [], + new AgentIdentity("agent-handoff-ux", "UX Specialist")); + + ChatMessageDto message = Assert.Single(messages); + Assert.Equal("UX Specialist", message.AuthorName); + Assert.Equal("Real content", message.Content); + } + private static PatternAgentDefinitionDto CreateAgent(string id, string name) { return new PatternAgentDefinitionDto