.NET: Fix CopySessionConfig() and CopyResumeSessionConfig() to preserve SessionConfig.Streaming value (#6463)

* Fix CopySessionConfig and CopyResumeSessionConfig ignoring Streaming value (#4732)

CopySessionConfig() and CopyResumeSessionConfig() hardcoded Streaming = true,
ignoring the caller's explicitly set SessionConfig.Streaming value. This made it
impossible to disable streaming when using AsAIAgent() with the GitHub Copilot SDK.

Changed both methods to use source.Streaming ?? true (and source?.Streaming ?? true
for the nullable overload), preserving the caller's value when set while maintaining
backward compatibility by defaulting to true when unset.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix non-streaming response path for SessionConfig.Streaming=false (#4732)

The config-copy fix (preserving Streaming=false via null-coalescing) was
already in place, but ConvertToAgentResponseUpdate(AssistantMessageEvent)
always emitted raw AIContent without text—assuming delta events had already
delivered it. When streaming is disabled there are no delta events, so the
assistant's final text was silently dropped.

Changes:
- Add isStreaming parameter to ConvertToAgentResponseUpdate for
  AssistantMessageEvent so it emits TextContent in non-streaming mode.
- Capture the resolved streaming flag in RunCoreStreamingAsync and pass
  it through the event subscription closure.
- Add/update unit tests for both streaming and non-streaming paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add test for null Data path in ConvertToAgentResponseUpdate (#4732)

Add a regression test covering the null-propagation path where
AssistantMessageEvent.Data is null. The production code already handles
this via ?. operators, but no test previously verified the behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giles Odigwe
2026-06-11 11:18:05 -07:00
committed by GitHub
Unverified
parent df29af611c
commit 8b0405de1b
2 changed files with 164 additions and 16 deletions
@@ -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
}
/// <summary>
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance
/// with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance,
/// preserving <see cref="SessionConfigBase.Streaming"/> from the source (defaulting to <c>true</c> if unset).
/// </summary>
internal static SessionConfig CopySessionConfig(SessionConfig source)
{
SessionConfig copy = source.Clone();
copy.Streaming = true;
copy.Streaming = source.Streaming ?? true;
return copy;
}
/// <summary>
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new
/// <see cref="ResumeSessionConfig"/> with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
/// <see cref="ResumeSessionConfig"/>, preserving <see cref="SessionConfigBase.Streaming"/>
/// from the source (defaulting to <c>true</c> if unset).
/// </summary>
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)
/// <summary>
/// Converts an <see cref="AssistantMessageEvent"/> to an <see cref="AgentResponseUpdate"/>.
/// 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 <see cref="TextContent"/>.
/// </summary>
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])
{
@@ -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);
}
}