Skip to content

Commit 5a19ab3

Browse files
committed
Fix GitHubCopilotAgent producing duplicated text content (#3979)
When streaming responses, GitHubCopilotAgent emitted TextContent from both AssistantMessageDeltaEvent (incremental chunks) and AssistantMessageEvent (complete assembled message). Consumers concatenating all streaming updates would see the full text duplicated. Root cause: RunCoreStreamingAsync had an explicit case for AssistantMessageEvent that called ConvertToAgentResponseUpdate, which created a TextContent with the full assembled text — the same text already streamed via delta events. Fix: - Remove the dedicated AssistantMessageEvent case from the streaming switch so it falls through to the default handler, which stores the event as RawRepresentation only (no TextContent). - Change ConvertToAgentResponseUpdate(AssistantMessageEvent) to return a generic AIContent with RawRepresentation instead of TextContent, matching the Python SDK approach. - Change ConvertToAgentResponseUpdate methods from private to internal to enable direct unit testing. Tests added (GitHubCopilotAgentDuplicateTextTests.cs — 8 tests): - ConvertDeltaEvent_ProducesTextContent - ConvertDeltaEvent_PreservesMessageId - ConvertAssistantMessageEvent_DoesNotProduceTextContent - ConvertAssistantMessageEvent_PreservesIdsAndTimestamp - StreamingSimulation_DeltasPlusComplete_NoDuplicatedText - ConvertDeltaEvent_EmptyDeltaContent_ProducesEmptyTextContent - ConvertUsageEvent_ProducesUsageContent_NotTextContent - ConvertSessionEvent_ProducesRawContent_NotTextContent All 22 existing + 8 new unit tests pass on net8.0, net9.0 and net10.0. Fixes #3979
1 parent 75ff4f4 commit 5a19ab3

2 files changed

Lines changed: 240 additions & 9 deletions

File tree

dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,11 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
177177
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(deltaEvent));
178178
break;
179179

180-
case AssistantMessageEvent assistantMessage:
181-
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage));
182-
break;
180+
// AssistantMessageEvent is intentionally NOT handled here.
181+
// It contains the full assembled text of all deltas, and emitting it
182+
// as TextContent would duplicate what was already streamed via
183+
// AssistantMessageDeltaEvent (see issue #3979).
184+
// It falls through to the default case for raw representation only.
183185

184186
case AssistantUsageEvent usageEvent:
185187
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent));
@@ -331,7 +333,7 @@ internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? sourc
331333
};
332334
}
333335

334-
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent)
336+
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent)
335337
{
336338
TextContent textContent = new(deltaEvent.Data?.DeltaContent ?? string.Empty)
337339
{
@@ -346,14 +348,16 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEv
346348
};
347349
}
348350

349-
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
351+
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
350352
{
351-
TextContent textContent = new(assistantMessage.Data?.Content ?? string.Empty)
353+
// Store as raw representation only — no TextContent — to avoid duplicating
354+
// the text that was already streamed via AssistantMessageDeltaEvent (issue #3979).
355+
AIContent content = new()
352356
{
353357
RawRepresentation = assistantMessage
354358
};
355359

356-
return new AgentResponseUpdate(ChatRole.Assistant, [textContent])
360+
return new AgentResponseUpdate(ChatRole.Assistant, [content])
357361
{
358362
AgentId = this.Id,
359363
ResponseId = assistantMessage.Data?.MessageId,
@@ -362,7 +366,7 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent a
362366
};
363367
}
364368

365-
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent)
369+
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent)
366370
{
367371
UsageDetails usageDetails = new()
368372
{
@@ -415,7 +419,7 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usa
415419
return additionalCounts;
416420
}
417421

418-
private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent)
422+
internal AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent)
419423
{
420424
// Handle arbitrary events by storing as RawRepresentation
421425
AIContent content = new()
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
using GitHub.Copilot.SDK;
8+
using Microsoft.Extensions.AI;
9+
10+
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
11+
12+
/// <summary>
13+
/// Tests that verify the fix for issue #3979 — GitHubCopilotAgent produces duplicated text content.
14+
/// The bug was caused by both AssistantMessageDeltaEvent (incremental chunks) and
15+
/// AssistantMessageEvent (complete assembled message) producing TextContent in
16+
/// the streaming output. Consumers concatenating all update text would see the
17+
/// content twice.
18+
/// </summary>
19+
public sealed class GitHubCopilotAgentDuplicateTextTests : IAsyncDisposable
20+
{
21+
private readonly GitHubCopilotAgent _agent;
22+
23+
public GitHubCopilotAgentDuplicateTextTests()
24+
{
25+
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
26+
_agent = new GitHubCopilotAgent(copilotClient, sessionConfig: null, ownsClient: false, id: "test-agent", name: "Test Agent", description: "Test agent");
27+
}
28+
29+
public ValueTask DisposeAsync() => _agent.DisposeAsync();
30+
31+
[Fact]
32+
public void ConvertDeltaEvent_ProducesTextContent()
33+
{
34+
// Arrange
35+
var deltaEvent = new AssistantMessageDeltaEvent
36+
{
37+
Data = new AssistantMessageDeltaData
38+
{
39+
DeltaContent = "Hello ",
40+
MessageId = "msg-1",
41+
},
42+
};
43+
44+
// Act
45+
AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(deltaEvent);
46+
47+
// Assert — delta events MUST produce TextContent for streaming
48+
Assert.NotNull(update);
49+
Assert.Single(update.Contents);
50+
TextContent textContent = Assert.IsType<TextContent>(update.Contents[0]);
51+
Assert.Equal("Hello ", textContent.Text);
52+
Assert.Same(deltaEvent, textContent.RawRepresentation);
53+
}
54+
55+
[Fact]
56+
public void ConvertDeltaEvent_PreservesMessageId()
57+
{
58+
// Arrange
59+
var deltaEvent = new AssistantMessageDeltaEvent
60+
{
61+
Data = new AssistantMessageDeltaData
62+
{
63+
DeltaContent = "test",
64+
MessageId = "msg-42",
65+
},
66+
};
67+
68+
// Act
69+
AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(deltaEvent);
70+
71+
// Assert
72+
Assert.Equal("msg-42", update.MessageId);
73+
Assert.Equal("test-agent", update.AgentId);
74+
Assert.Equal(ChatRole.Assistant, update.Role);
75+
}
76+
77+
[Fact]
78+
public void ConvertAssistantMessageEvent_DoesNotProduceTextContent()
79+
{
80+
// Arrange — AssistantMessageEvent contains the full assembled text
81+
var messageEvent = new AssistantMessageEvent
82+
{
83+
Data = new AssistantMessageData
84+
{
85+
Content = "Hello world! This is the complete message.",
86+
MessageId = "msg-1",
87+
},
88+
};
89+
90+
// Act
91+
AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(messageEvent);
92+
93+
// Assert — must NOT produce TextContent to avoid duplicating delta text (#3979)
94+
Assert.NotNull(update);
95+
Assert.Single(update.Contents);
96+
Assert.IsNotType<TextContent>(update.Contents[0]);
97+
Assert.Same(messageEvent, update.Contents[0].RawRepresentation);
98+
}
99+
100+
[Fact]
101+
public void ConvertAssistantMessageEvent_PreservesIdsAndTimestamp()
102+
{
103+
// Arrange
104+
var messageEvent = new AssistantMessageEvent
105+
{
106+
Data = new AssistantMessageData
107+
{
108+
Content = "complete text",
109+
MessageId = "msg-99",
110+
},
111+
};
112+
113+
// Act
114+
AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(messageEvent);
115+
116+
// Assert — metadata is preserved even without TextContent
117+
Assert.Equal("msg-99", update.MessageId);
118+
Assert.Equal("msg-99", update.ResponseId);
119+
Assert.Equal("test-agent", update.AgentId);
120+
Assert.Equal(ChatRole.Assistant, update.Role);
121+
}
122+
123+
[Fact]
124+
public void StreamingSimulation_DeltasPlusComplete_NoDuplicatedText()
125+
{
126+
// Arrange — simulate the event sequence: 3 deltas + 1 complete message
127+
const string Part1 = "Hello ";
128+
const string Part2 = "world";
129+
const string Part3 = "!";
130+
const string FullText = Part1 + Part2 + Part3;
131+
132+
var delta1 = new AssistantMessageDeltaEvent
133+
{
134+
Data = new AssistantMessageDeltaData { DeltaContent = Part1, MessageId = "msg-1" },
135+
};
136+
var delta2 = new AssistantMessageDeltaEvent
137+
{
138+
Data = new AssistantMessageDeltaData { DeltaContent = Part2, MessageId = "msg-1" },
139+
};
140+
var delta3 = new AssistantMessageDeltaEvent
141+
{
142+
Data = new AssistantMessageDeltaData { DeltaContent = Part3, MessageId = "msg-1" },
143+
};
144+
var completeMessage = new AssistantMessageEvent
145+
{
146+
Data = new AssistantMessageData { Content = FullText, MessageId = "msg-1" },
147+
};
148+
149+
// Act — convert all events (as would happen in the streaming pipeline)
150+
var updates = new List<AgentResponseUpdate>
151+
{
152+
_agent.ConvertToAgentResponseUpdate(delta1),
153+
_agent.ConvertToAgentResponseUpdate(delta2),
154+
_agent.ConvertToAgentResponseUpdate(delta3),
155+
_agent.ConvertToAgentResponseUpdate(completeMessage),
156+
};
157+
158+
// Assert — collect all TextContent from all updates
159+
string collectedText = string.Concat(
160+
updates.SelectMany(u => u.Contents)
161+
.OfType<TextContent>()
162+
.Select(tc => tc.Text));
163+
164+
// The collected text must equal the full text exactly once (no duplication)
165+
Assert.Equal(FullText, collectedText);
166+
}
167+
168+
[Fact]
169+
public void ConvertDeltaEvent_EmptyDeltaContent_ProducesEmptyTextContent()
170+
{
171+
// Arrange — DeltaContent is empty string
172+
var deltaEvent = new AssistantMessageDeltaEvent
173+
{
174+
Data = new AssistantMessageDeltaData { DeltaContent = string.Empty, MessageId = "msg-empty" },
175+
};
176+
177+
// Act
178+
AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(deltaEvent);
179+
180+
// Assert — empty delta produces empty TextContent (defensive behavior)
181+
TextContent textContent = Assert.IsType<TextContent>(update.Contents[0]);
182+
Assert.Equal(string.Empty, textContent.Text);
183+
}
184+
185+
[Fact]
186+
public void ConvertUsageEvent_ProducesUsageContent_NotTextContent()
187+
{
188+
// Arrange
189+
var usageEvent = new AssistantUsageEvent
190+
{
191+
Data = new AssistantUsageData
192+
{
193+
Model = "gpt-4o",
194+
InputTokens = 10,
195+
OutputTokens = 25,
196+
},
197+
};
198+
199+
// Act
200+
AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate(usageEvent);
201+
202+
// Assert — usage events should produce UsageContent, not TextContent
203+
Assert.Single(update.Contents);
204+
UsageContent usageContent = Assert.IsType<UsageContent>(update.Contents[0]);
205+
Assert.Equal(10, usageContent.Details.InputTokenCount);
206+
Assert.Equal(25, usageContent.Details.OutputTokenCount);
207+
Assert.Equal(35, usageContent.Details.TotalTokenCount);
208+
}
209+
210+
[Fact]
211+
public void ConvertSessionEvent_ProducesRawContent_NotTextContent()
212+
{
213+
// Arrange — generic session event (falls to default handler)
214+
var sessionEvent = new SessionIdleEvent
215+
{
216+
Data = new SessionIdleData(),
217+
};
218+
219+
// Act
220+
AgentResponseUpdate update = _agent.ConvertToAgentResponseUpdate((SessionEvent)sessionEvent);
221+
222+
// Assert
223+
Assert.Single(update.Contents);
224+
Assert.IsNotType<TextContent>(update.Contents[0]);
225+
Assert.Same(sessionEvent, update.Contents[0].RawRepresentation);
226+
}
227+
}

0 commit comments

Comments
 (0)