diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index 053a08f725..a85c126fd6 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -145,11 +145,12 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable // Ensure the client is started await this.EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false); - // Create or resume a session with streaming enabled + // Create or resume a session with streaming enabled by default SessionConfig sessionConfig = this._sessionConfig != null ? CopySessionConfig(this._sessionConfig) : new SessionConfig { Streaming = true }; + bool isStreaming = sessionConfig.Streaming ?? true; CopilotSession copilotSession; if (typedSession.SessionId is not null) { @@ -178,7 +179,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable break; case AssistantMessageEvent assistantMessage: - channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage)); + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage, isStreaming)); break; case AssistantUsageEvent usageEvent: @@ -271,19 +272,20 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable } /// - /// Copies all supported properties from a source into a new instance - /// with set to true. + /// Copies all supported properties from a source into a new instance, + /// preserving from the source (defaulting to true if unset). /// internal static SessionConfig CopySessionConfig(SessionConfig source) { SessionConfig copy = source.Clone(); - copy.Streaming = true; + copy.Streaming = source.Streaming ?? true; return copy; } /// /// Copies all supported properties from a source into a new - /// with set to true. + /// , preserving + /// from the source (defaulting to true if unset). /// internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source) { @@ -306,7 +308,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable SkillDirectories = source?.SkillDirectories, DisabledSkills = source?.DisabledSkills, InfiniteSessions = source?.InfiniteSessions, - Streaming = true + Streaming = source?.Streaming ?? true }; } @@ -325,12 +327,18 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable }; } - internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage) + /// + /// Converts an to an . + /// When streaming is enabled, text was already delivered via delta events, so only raw metadata is emitted. + /// When streaming is disabled, the full message text is emitted as . + /// + internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage, bool isStreaming) { - AIContent content = new() - { - RawRepresentation = assistantMessage - }; + // When streaming, text was already delivered via AssistantMessageDeltaEvent. + // When not streaming, this is the only opportunity to emit the response text. + AIContent content = isStreaming + ? new AIContent { RawRepresentation = assistantMessage } + : new TextContent(assistantMessage.Data?.Content ?? string.Empty) { RawRepresentation = assistantMessage }; return new AgentResponseUpdate(ChatRole.Assistant, [content]) { diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index 944f2f30ab..8faa842eb0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using GitHub.Copilot; using GitHub.Copilot.Rpc; @@ -222,7 +223,73 @@ public sealed class GitHubCopilotAgentTests } [Fact] - public void ConvertToAgentResponseUpdate_AssistantMessageEvent_DoesNotEmitTextContent() + public void CopySessionConfig_WithStreamingDisabled_PreservesStreamingValue() + { + // Arrange + var source = new SessionConfig + { + Streaming = false, + Model = "gpt-4o", + }; + + // Act + SessionConfig result = GitHubCopilotAgent.CopySessionConfig(source); + + // Assert + Assert.False(result.Streaming); + } + + [Fact] + public void CopySessionConfig_WithStreamingNull_DefaultsToTrue() + { + // Arrange + var source = new SessionConfig + { + Model = "gpt-4o", + }; + + // Act + SessionConfig result = GitHubCopilotAgent.CopySessionConfig(source); + + // Assert + Assert.True(result.Streaming); + } + + [Fact] + public void CopyResumeSessionConfig_WithStreamingDisabled_PreservesStreamingValue() + { + // Arrange + var source = new SessionConfig + { + Streaming = false, + Model = "gpt-4o", + }; + + // Act + ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(source); + + // Assert + Assert.False(result.Streaming); + } + + [Fact] + public void CopyResumeSessionConfig_WithStreamingNull_DefaultsToTrue() + { + // Arrange + var source = new SessionConfig + { + Model = "gpt-4o", + }; + + // Act + ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(source); + + // Assert + Assert.True(result.Streaming); + } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenStreaming_DoesNotEmitTextContent() { var assistantMessage = new AssistantMessageEvent { @@ -235,11 +302,84 @@ public sealed class GitHubCopilotAgentTests CopilotClient copilotClient = new(new CopilotClientOptions()); const string TestId = "agent-id"; var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); - AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage); + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: true); - // result.Text need to be empty because the content was already delivered via delta events, and we want to avoid emitting duplicate content in the response update. - // The content should be delivered through TextContent in the Contents collection instead. + // result.Text should be empty because content was already delivered via delta events. Assert.Empty(result.Text); Assert.DoesNotContain(result.Contents, c => c is TextContent); } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_EmitsTextContent() + { + // Arrange + const string ExpectedContent = "Full response text from non-streaming session"; + var assistantMessage = new AssistantMessageEvent + { + Data = new AssistantMessageData + { + MessageId = "msg-789", + Content = ExpectedContent + } + }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + const string TestId = "agent-id"; + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); + + // Act + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false); + + // Assert - text must be emitted since no delta events precede it in non-streaming mode. + Assert.Equal(ExpectedContent, result.Text); + Assert.Contains(result.Contents, c => c is TextContent); + TextContent textContent = (TextContent)result.Contents.Single(c => c is TextContent); + Assert.Equal(ExpectedContent, textContent.Text); + Assert.Same(assistantMessage, textContent.RawRepresentation); + } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesEmptyContent() + { + // Arrange + var assistantMessage = new AssistantMessageEvent + { + Data = new AssistantMessageData + { + MessageId = "msg-000", + Content = string.Empty + } + }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + const string TestId = "agent-id"; + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); + + // Act + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false); + + // Assert - should emit empty TextContent rather than throwing. + Assert.Empty(result.Text); + Assert.Contains(result.Contents, c => c is TextContent); + } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesNullData() + { + // Arrange + var assistantMessage = new AssistantMessageEvent + { + Data = null! + }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + const string TestId = "agent-id"; + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); + + // Act + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false); + + // Assert - null Data should produce empty TextContent via null-propagation fallback. + Assert.Empty(result.Text); + Assert.Contains(result.Contents, c => c is TextContent); + Assert.Null(result.MessageId); + Assert.Null(result.ResponseId); + } }