diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index c966f591fc..d7112bd1e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -181,6 +181,14 @@ protected override async IAsyncEnumerable RunCoreStreamingA channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage)); break; + case AssistantReasoningDeltaEvent reasoningDeltaEvent: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(reasoningDeltaEvent)); + break; + + case AssistantReasoningEvent reasoningEvent: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(reasoningEvent)); + break; + case AssistantUsageEvent usageEvent: channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent)); break; @@ -385,6 +393,34 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usa }; } + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantReasoningDeltaEvent reasoningDeltaEvent) + { + TextReasoningContent reasoningContent = new(reasoningDeltaEvent.Data?.DeltaContent ?? string.Empty) + { + RawRepresentation = reasoningDeltaEvent + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [reasoningContent]) + { + AgentId = this.Id, + CreatedAt = reasoningDeltaEvent.Timestamp + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantReasoningEvent reasoningEvent) + { + TextReasoningContent reasoningContent = new(reasoningEvent.Data?.Content ?? string.Empty) + { + RawRepresentation = reasoningEvent + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [reasoningContent]) + { + AgentId = this.Id, + CreatedAt = reasoningEvent.Timestamp + }; + } + private static AdditionalPropertiesDictionary? GetAdditionalCounts(AssistantUsageEvent usageEvent) { if (usageEvent.Data is null) diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentReasoningTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentReasoningTests.cs new file mode 100644 index 0000000000..7d45fdfc80 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentReasoningTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests; + +/// +/// Unit tests for the reasoning event handling. +/// +public sealed class GitHubCopilotAgentReasoningTests +{ + /// + /// Tests that ConvertToAgentResponseUpdate correctly handles AssistantReasoningDeltaEvent. + /// + [Fact] + public void ConvertToAgentResponseUpdate_WithReasoningDeltaEvent_CreatesTextReasoningContent() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + GitHubCopilotAgent agent = new(copilotClient, sessionConfig: null, ownsClient: false); + + // Create an AssistantReasoningDeltaEvent using reflection + AssistantReasoningDeltaEvent reasoningDeltaEvent = new() + { + Data = new AssistantReasoningDeltaData + { + ReasoningId = "reasoning-123", + DeltaContent = "Thinking step " + }, + Timestamp = DateTimeOffset.UtcNow + }; + + // Act - Use reflection to call the private method + MethodInfo? method = typeof(GitHubCopilotAgent).GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.NonPublic | BindingFlags.Instance, + null, + [typeof(AssistantReasoningDeltaEvent)], + null); + + Assert.NotNull(method); + + AgentResponseUpdate? result = method.Invoke(agent, [reasoningDeltaEvent]) as AgentResponseUpdate; + + // Assert + Assert.NotNull(result); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.NotEmpty(result.Contents); + + AIContent content = result.Contents[0]; + Assert.IsType(content); + + TextReasoningContent reasoningContent = (TextReasoningContent)content; + Assert.Equal("Thinking step ", reasoningContent.Text); + Assert.Equal(reasoningDeltaEvent, reasoningContent.RawRepresentation); + Assert.Equal(agent.Id, result.AgentId); + Assert.Equal(reasoningDeltaEvent.Timestamp, result.CreatedAt); + } + + /// + /// Tests that ConvertToAgentResponseUpdate correctly handles AssistantReasoningEvent. + /// + [Fact] + public void ConvertToAgentResponseUpdate_WithReasoningEvent_CreatesTextReasoningContent() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + GitHubCopilotAgent agent = new(copilotClient, sessionConfig: null, ownsClient: false); + + // Create an AssistantReasoningEvent using reflection + AssistantReasoningEvent reasoningEvent = new() + { + Data = new AssistantReasoningData + { + ReasoningId = "reasoning-456", + Content = "Complete reasoning content" + }, + Timestamp = DateTimeOffset.UtcNow + }; + + // Act - Use reflection to call the private method + MethodInfo? method = typeof(GitHubCopilotAgent).GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.NonPublic | BindingFlags.Instance, + null, + [typeof(AssistantReasoningEvent)], + null); + + Assert.NotNull(method); + + AgentResponseUpdate? result = method.Invoke(agent, [reasoningEvent]) as AgentResponseUpdate; + + // Assert + Assert.NotNull(result); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.NotEmpty(result.Contents); + + AIContent content = result.Contents[0]; + Assert.IsType(content); + + TextReasoningContent reasoningContent = (TextReasoningContent)content; + Assert.Equal("Complete reasoning content", reasoningContent.Text); + Assert.Equal(reasoningEvent, reasoningContent.RawRepresentation); + Assert.Equal(agent.Id, result.AgentId); + Assert.Equal(reasoningEvent.Timestamp, result.CreatedAt); + } + + /// + /// Tests that ConvertToAgentResponseUpdate handles null data in AssistantReasoningDeltaEvent. + /// + [Fact] + public void ConvertToAgentResponseUpdate_WithNullDataInReasoningDeltaEvent_CreatesEmptyTextReasoningContent() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + GitHubCopilotAgent agent = new(copilotClient, sessionConfig: null, ownsClient: false); + + // Create an AssistantReasoningDeltaEvent with null data + AssistantReasoningDeltaEvent reasoningDeltaEvent = new() + { + Data = null!, + Timestamp = DateTimeOffset.UtcNow + }; + + // Act - Use reflection to call the private method + MethodInfo? method = typeof(GitHubCopilotAgent).GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.NonPublic | BindingFlags.Instance, + null, + [typeof(AssistantReasoningDeltaEvent)], + null); + + Assert.NotNull(method); + + AgentResponseUpdate? result = method.Invoke(agent, [reasoningDeltaEvent]) as AgentResponseUpdate; + + // Assert + Assert.NotNull(result); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.NotEmpty(result.Contents); + + AIContent content = result.Contents[0]; + Assert.IsType(content); + + TextReasoningContent reasoningContent = (TextReasoningContent)content; + Assert.Equal(string.Empty, reasoningContent.Text); + } + + /// + /// Tests that ConvertToAgentResponseUpdate handles null content in AssistantReasoningEvent. + /// + [Fact] + public void ConvertToAgentResponseUpdate_WithNullDataInReasoningEvent_CreatesEmptyTextReasoningContent() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + GitHubCopilotAgent agent = new(copilotClient, sessionConfig: null, ownsClient: false); + + // Create an AssistantReasoningEvent with null data + AssistantReasoningEvent reasoningEvent = new() + { + Data = null!, + Timestamp = DateTimeOffset.UtcNow + }; + + // Act - Use reflection to call the private method + MethodInfo? method = typeof(GitHubCopilotAgent).GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.NonPublic | BindingFlags.Instance, + null, + [typeof(AssistantReasoningEvent)], + null); + + Assert.NotNull(method); + + AgentResponseUpdate? result = method.Invoke(agent, [reasoningEvent]) as AgentResponseUpdate; + + // Assert + Assert.NotNull(result); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.NotEmpty(result.Contents); + + AIContent content = result.Contents[0]; + Assert.IsType(content); + + TextReasoningContent reasoningContent = (TextReasoningContent)content; + Assert.Equal(string.Empty, reasoningContent.Text); + } + + /// + /// Tests that ConvertToAgentResponseUpdate handles null DeltaContent in AssistantReasoningDeltaEvent. + /// + [Fact] + public void ConvertToAgentResponseUpdate_WithNullDeltaContent_CreatesEmptyTextReasoningContent() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + GitHubCopilotAgent agent = new(copilotClient, sessionConfig: null, ownsClient: false); + + // Create an AssistantReasoningDeltaEvent with null DeltaContent + AssistantReasoningDeltaEvent reasoningDeltaEvent = new() + { + Data = new AssistantReasoningDeltaData + { + ReasoningId = "reasoning-789", + DeltaContent = null! + }, + Timestamp = DateTimeOffset.UtcNow + }; + + // Act - Use reflection to call the private method + MethodInfo? method = typeof(GitHubCopilotAgent).GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.NonPublic | BindingFlags.Instance, + null, + [typeof(AssistantReasoningDeltaEvent)], + null); + + Assert.NotNull(method); + + AgentResponseUpdate? result = method.Invoke(agent, [reasoningDeltaEvent]) as AgentResponseUpdate; + + // Assert + Assert.NotNull(result); + AIContent content = result.Contents[0]; + TextReasoningContent reasoningContent = Assert.IsType(content); + Assert.Equal(string.Empty, reasoningContent.Text); + } + + /// + /// Tests that ConvertToAgentResponseUpdate handles null Content in AssistantReasoningEvent. + /// + [Fact] + public void ConvertToAgentResponseUpdate_WithNullContent_CreatesEmptyTextReasoningContent() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + GitHubCopilotAgent agent = new(copilotClient, sessionConfig: null, ownsClient: false); + + // Create an AssistantReasoningEvent with null Content + AssistantReasoningEvent reasoningEvent = new() + { + Data = new AssistantReasoningData + { + ReasoningId = "reasoning-999", + Content = null! + }, + Timestamp = DateTimeOffset.UtcNow + }; + + // Act - Use reflection to call the private method + MethodInfo? method = typeof(GitHubCopilotAgent).GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.NonPublic | BindingFlags.Instance, + null, + [typeof(AssistantReasoningEvent)], + null); + + Assert.NotNull(method); + + AgentResponseUpdate? result = method.Invoke(agent, [reasoningEvent]) as AgentResponseUpdate; + + // Assert + Assert.NotNull(result); + AIContent content = result.Contents[0]; + TextReasoningContent reasoningContent = Assert.IsType(content); + Assert.Equal(string.Empty, reasoningContent.Text); + } +} diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 053e0d3de0..246e8eef4c 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -453,6 +453,22 @@ def event_handler(event: SessionEvent) -> None: raw_representation=event, ) queue.put_nowait(update) + elif event.type == SessionEventType.ASSISTANT_REASONING_DELTA: + if event.data.delta_content: + update = AgentResponseUpdate( + role=Role.ASSISTANT, + contents=[Content.from_text_reasoning(event.data.delta_content)], + raw_representation=event, + ) + queue.put_nowait(update) + elif event.type == SessionEventType.ASSISTANT_REASONING: + if event.data.content: + update = AgentResponseUpdate( + role=Role.ASSISTANT, + contents=[Content.from_text_reasoning(event.data.content)], + raw_representation=event, + ) + queue.put_nowait(update) elif event.type == SessionEventType.SESSION_IDLE: queue.put_nowait(None) elif event.type == SessionEventType.SESSION_ERROR: diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 1e9281d21e..dc218d1738 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -100,6 +100,24 @@ def session_error_event() -> SessionEvent: ) +@pytest.fixture +def assistant_reasoning_delta_event() -> SessionEvent: + """Create a mock assistant reasoning delta event.""" + return create_session_event( + SessionEventType.ASSISTANT_REASONING_DELTA, + delta_content="Thinking step ", + ) + + +@pytest.fixture +def assistant_reasoning_event() -> SessionEvent: + """Create a mock assistant reasoning event.""" + return create_session_event( + SessionEventType.ASSISTANT_REASONING, + content="Complete reasoning content", + ) + + class TestGitHubCopilotAgentInit: """Test cases for GitHubCopilotAgent initialization.""" @@ -457,6 +475,64 @@ def mock_on(handler: Any) -> Any: assert agent._started is True # type: ignore mock_client.start.assert_called_once() + async def test_run_stream_with_reasoning_delta( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_reasoning_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test streaming with reasoning delta events.""" + events = [assistant_reasoning_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + agent = GitHubCopilotAgent(client=mock_client) + responses: list[AgentResponseUpdate] = [] + async for update in agent.run_stream("Hello"): + responses.append(update) + + assert len(responses) == 1 + assert isinstance(responses[0], AgentResponseUpdate) + assert responses[0].role == Role.ASSISTANT + # Check that the content is TextReasoningContent by checking its type + assert responses[0].contents[0].type == "text_reasoning" + assert responses[0].contents[0].text == "Thinking step " + + async def test_run_stream_with_reasoning_complete( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_reasoning_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test streaming with complete reasoning events.""" + events = [assistant_reasoning_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + agent = GitHubCopilotAgent(client=mock_client) + responses: list[AgentResponseUpdate] = [] + async for update in agent.run_stream("Hello"): + responses.append(update) + + assert len(responses) == 1 + assert isinstance(responses[0], AgentResponseUpdate) + assert responses[0].role == Role.ASSISTANT + # Check that the content is TextReasoningContent by checking its type + assert responses[0].contents[0].type == "text_reasoning" + assert responses[0].contents[0].text == "Complete reasoning content" + class TestGitHubCopilotAgentSessionManagement: """Test cases for session management."""