diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index c966f591fc..2b6941dd16 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -177,9 +177,11 @@ protected override async IAsyncEnumerable RunCoreStreamingA channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(deltaEvent)); break; - case AssistantMessageEvent assistantMessage: - channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage)); - break; + // AssistantMessageEvent is intentionally NOT handled here. + // It contains the full assembled text of all deltas, and emitting it + // as TextContent would duplicate what was already streamed via + // AssistantMessageDeltaEvent (see issue #3979). + // It falls through to the default case for raw representation only. case AssistantUsageEvent usageEvent: channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent)); @@ -331,7 +333,7 @@ internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? sourc }; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent) + internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent) { TextContent textContent = new(deltaEvent.Data?.DeltaContent ?? string.Empty) { @@ -346,14 +348,16 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEv }; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage) + internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage) { - TextContent textContent = new(assistantMessage.Data?.Content ?? string.Empty) + // Store as raw representation only — no TextContent — to avoid duplicating + // the text that was already streamed via AssistantMessageDeltaEvent (issue #3979). + AIContent content = new() { RawRepresentation = assistantMessage }; - return new AgentResponseUpdate(ChatRole.Assistant, [textContent]) + return new AgentResponseUpdate(ChatRole.Assistant, [content]) { AgentId = this.Id, ResponseId = assistantMessage.Data?.MessageId, @@ -362,7 +366,7 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent a }; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent) + internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent) { UsageDetails usageDetails = new() { @@ -415,7 +419,7 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usa return additionalCounts; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent) + internal AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent) { // Handle arbitrary events by storing as RawRepresentation AIContent content = new() diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentDuplicateTextTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentDuplicateTextTests.cs new file mode 100644 index 0000000000..c39b535514 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentDuplicateTextTests.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests; + +/// +/// Tests that verify the fix for issue #3979 — GitHubCopilotAgent produces duplicated text content. +/// The bug was caused by both AssistantMessageDeltaEvent (incremental chunks) and +/// AssistantMessageEvent (complete assembled message) producing TextContent in +/// the streaming output. Consumers concatenating all update text would see the +/// content twice. +/// +public sealed class GitHubCopilotAgentDuplicateTextTests : IAsyncDisposable +{ + private readonly GitHubCopilotAgent _agent; + + public GitHubCopilotAgentDuplicateTextTests() + { + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + _agent = new GitHubCopilotAgent(copilotClient, sessionConfig: null, ownsClient: false, id: "test-agent", name: "Test Agent", description: "Test agent"); + } + + public ValueTask DisposeAsync() => _agent.DisposeAsync(); + + [Fact] + public void ConvertDeltaEvent_ProducesTextContent() + { + // Arrange + var deltaEvent = new AssistantMessageDeltaEvent + { + Data = new AssistantMessageDeltaData + { + DeltaContent = "Hello ", + MessageId = "msg-1", + }, + }; + + // Act + AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(deltaEvent); + + // Assert — delta events MUST produce TextContent for streaming + Assert.NotNull(update); + Assert.Single(update.Contents); + TextContent textContent = Assert.IsType(update.Contents[0]); + Assert.Equal("Hello ", textContent.Text); + Assert.Same(deltaEvent, textContent.RawRepresentation); + } + + [Fact] + public void ConvertDeltaEvent_PreservesMessageId() + { + // Arrange + var deltaEvent = new AssistantMessageDeltaEvent + { + Data = new AssistantMessageDeltaData + { + DeltaContent = "test", + MessageId = "msg-42", + }, + }; + + // Act + AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(deltaEvent); + + // Assert + Assert.Equal("msg-42", update.MessageId); + Assert.Equal("test-agent", update.AgentId); + Assert.Equal(ChatRole.Assistant, update.Role); + } + + [Fact] + public void ConvertAssistantMessageEvent_DoesNotProduceTextContent() + { + // Arrange — AssistantMessageEvent contains the full assembled text + var messageEvent = new AssistantMessageEvent + { + Data = new AssistantMessageData + { + Content = "Hello world! This is the complete message.", + MessageId = "msg-1", + }, + }; + + // Act + AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(messageEvent); + + // Assert — must NOT produce TextContent to avoid duplicating delta text (#3979) + Assert.NotNull(update); + Assert.Single(update.Contents); + Assert.IsNotType(update.Contents[0]); + Assert.Same(messageEvent, update.Contents[0].RawRepresentation); + } + + [Fact] + public void ConvertAssistantMessageEvent_PreservesIdsAndTimestamp() + { + // Arrange + var messageEvent = new AssistantMessageEvent + { + Data = new AssistantMessageData + { + Content = "complete text", + MessageId = "msg-99", + }, + }; + + // Act + AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(messageEvent); + + // Assert — metadata is preserved even without TextContent + Assert.Equal("msg-99", update.MessageId); + Assert.Equal("msg-99", update.ResponseId); + Assert.Equal("test-agent", update.AgentId); + Assert.Equal(ChatRole.Assistant, update.Role); + } + + [Fact] + public void StreamingSimulation_DeltasPlusComplete_NoDuplicatedText() + { + // Arrange — simulate the event sequence: 3 deltas + 1 complete message + const string Part1 = "Hello "; + const string Part2 = "world"; + const string Part3 = "!"; + const string FullText = Part1 + Part2 + Part3; + + var delta1 = new AssistantMessageDeltaEvent + { + Data = new AssistantMessageDeltaData { DeltaContent = Part1, MessageId = "msg-1" }, + }; + var delta2 = new AssistantMessageDeltaEvent + { + Data = new AssistantMessageDeltaData { DeltaContent = Part2, MessageId = "msg-1" }, + }; + var delta3 = new AssistantMessageDeltaEvent + { + Data = new AssistantMessageDeltaData { DeltaContent = Part3, MessageId = "msg-1" }, + }; + var completeMessage = new AssistantMessageEvent + { + Data = new AssistantMessageData { Content = FullText, MessageId = "msg-1" }, + }; + + // Act — convert all events (as would happen in the streaming pipeline) + var updates = new List + { + _agent.ConvertToAgentResponseUpdate(delta1), + _agent.ConvertToAgentResponseUpdate(delta2), + _agent.ConvertToAgentResponseUpdate(delta3), + _agent.ConvertToAgentResponseUpdate(completeMessage), + }; + + // Assert — collect all TextContent from all updates + string collectedText = string.Concat( + updates.SelectMany(u => u.Contents) + .OfType() + .Select(tc => tc.Text)); + + // The collected text must equal the full text exactly once (no duplication) + Assert.Equal(FullText, collectedText); + } + + [Fact] + public void ConvertDeltaEvent_EmptyDeltaContent_ProducesEmptyTextContent() + { + // Arrange — DeltaContent is empty string + var deltaEvent = new AssistantMessageDeltaEvent + { + Data = new AssistantMessageDeltaData { DeltaContent = string.Empty, MessageId = "msg-empty" }, + }; + + // Act + AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(deltaEvent); + + // Assert — empty delta produces empty TextContent (defensive behavior) + TextContent textContent = Assert.IsType(update.Contents[0]); + Assert.Equal(string.Empty, textContent.Text); + } + + [Fact] + public void ConvertUsageEvent_ProducesUsageContent_NotTextContent() + { + // Arrange + var usageEvent = new AssistantUsageEvent + { + Data = new AssistantUsageData + { + Model = "gpt-4o", + InputTokens = 10, + OutputTokens = 25, + }, + }; + + // Act + AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(usageEvent); + + // Assert — usage events should produce UsageContent, not TextContent + Assert.Single(update.Contents); + UsageContent usageContent = Assert.IsType(update.Contents[0]); + Assert.Equal(10, usageContent.Details.InputTokenCount); + Assert.Equal(25, usageContent.Details.OutputTokenCount); + Assert.Equal(35, usageContent.Details.TotalTokenCount); + } + + [Fact] + public void ConvertSessionEvent_ProducesRawContent_NotTextContent() + { + // Arrange — generic session event (falls to default handler) + var sessionEvent = new SessionIdleEvent + { + Data = new SessionIdleData(), + }; + + // Act + AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate((SessionEvent)sessionEvent); + + // Assert + Assert.Single(update.Contents); + Assert.IsNotType(update.Contents[0]); + Assert.Same(sessionEvent, update.Contents[0].RawRepresentation); + } +}