.NET: Populate AgentResponse metadata in CopilotStudioAgent#6791
.NET: Populate AgentResponse metadata in CopilotStudioAgent#6791anneheartrecord wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the .NET Copilot Studio provider to populate AgentResponse / AgentResponseUpdate metadata consistently with other agent implementations, and adds unit tests to validate the new mappings.
Changes:
- Map Copilot Studio activity
TimestamptoChatMessage.CreatedAtand propagate activityPropertiestoChatMessage.AdditionalProperties(null when empty). - Populate non-streaming
AgentResponsemetadata (CreatedAt,FinishReason,RawRepresentation,AdditionalProperties) from the final message. - Update streaming projection to carry
CreatedAton each update and setFinishReasononly on the terminal update, plus add unit tests and expose internals to the test assembly.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| dotnet/tests/Microsoft.Agents.AI.UnitTests/CopilotStudioAgentTests.cs | Adds unit tests covering metadata mapping for activities, responses, and streaming updates (including error propagation). |
| dotnet/src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj | Exposes internals to Microsoft.Agents.AI.UnitTests for testing ActivityProcessor and helpers. |
| dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs | Centralizes response/update creation and populates response-level metadata; refactors streaming updates to set terminal FinishReason. |
| dotnet/src/Microsoft.Agents.AI.CopilotStudio/ActivityProcessor.cs | Maps activity timestamp and activity properties into ChatMessage metadata. |
| // Buffer a single message so we know which update is the terminal one (it carries the finish reason). | ||
| // Manual enumeration lets us still emit any already-received content if the source faults mid-stream, | ||
| // preserving the original streaming behavior, before re-throwing the original exception. | ||
| ChatMessage? pending = null; | ||
| ExceptionDispatchInfo? failure = null; | ||
|
|
||
| IAsyncEnumerator<ChatMessage> enumerator = messages.GetAsyncEnumerator(cancellationToken); | ||
| try | ||
| { | ||
| // TODO: Review list of ChatResponse properties to ensure we set all availble values. | ||
| // Setting ResponseId and MessageId end up being particularly important for streaming consumers | ||
| // so that they can tell things like response boundaries. | ||
| yield return new AgentResponseUpdate(message.Role, message.Contents) | ||
| while (true) | ||
| { | ||
| AgentId = this.Id, | ||
| AdditionalProperties = message.AdditionalProperties, | ||
| AuthorName = message.AuthorName, | ||
| RawRepresentation = message.RawRepresentation, | ||
| ResponseId = message.MessageId, | ||
| MessageId = message.MessageId, | ||
| }; | ||
| bool moved; | ||
| try | ||
| { | ||
| moved = await enumerator.MoveNextAsync().ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| failure = ExceptionDispatchInfo.Capture(ex); | ||
| break; | ||
| } | ||
|
|
||
| if (!moved) | ||
| { | ||
| break; | ||
| } | ||
|
|
||
| if (pending is not null) | ||
| { | ||
| yield return CreateAgentResponseUpdate(pending, agentId, finishReason: null); | ||
| } | ||
|
|
||
| pending = enumerator.Current; | ||
| } |
There was a problem hiding this comment.
This one-message buffer is deliberate: a stream doesn't announce its final element ahead of time, so to set FinishReason=Stop on the terminal update we need one element of lookahead to know which update is last. The alternatives seemed worse — either emit no finish reason on any streamed update, or append a synthetic empty terminal update. The cost is a single-message delay, which is negligible for token streaming. Happy to switch to one of those approaches if you'd prefer.
| finally | ||
| { | ||
| await enumerator.DisposeAsync().ConfigureAwait(false); | ||
| } |
| { | ||
| // Arrange | ||
| var timestamp = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero); | ||
| var properties = new Dictionary<string, JsonElement> { ["channelId"] = JsonDocument.Parse("\"webchat\"").RootElement }; |
|
@giles17 since #6790 is assigned to you — this PR fills in the missing AgentResponse metadata (CreatedAt, FinishReason, RawRepresentation, AdditionalProperties) in CopilotStudioAgent for both the streaming and non-streaming paths. CI is green; happy to adjust if you'd rather take a different approach. |
|
Hey @anneheartrecord please can you address copilot comments and CI failures? |
Map CreatedAt, FinishReason, RawRepresentation and AdditionalProperties onto AgentResponse and AgentResponseUpdate, and map the activity timestamp and properties onto ChatMessage, so Copilot Studio agents expose the same metadata surface as other AIAgent implementations. Streaming sets the finish reason on the terminal update while still emitting already-received content if the source faults. Add unit tests covering the metadata mapping.
39d2615 to
c22e1c8
Compare
|
@giles17 Done. Rebased onto latest main — the build failure was |
Closes #6790.
CopilotStudioAgentonly setAgentId/ResponseIdon the non-streamingAgentResponse, leavingCreatedAt,FinishReason,RawRepresentationandAdditionalPropertiesunset; the streaming path was also missingCreatedAtandFinishReason. This brings the provider in line with the others (e.g.A2AAgent) and clears the existingTODOs in the source.Changes:
ActivityProcessormaps the activityTimestamptoChatMessage.CreatedAtand copiesIActivity.PropertiesintoChatMessage.AdditionalProperties(leftnullwhen there are none).RunAsyncpopulatesCreatedAt,FinishReason(Stop),RawRepresentationandAdditionalPropertiesfrom the final message.RunStreamingAsynccarriesCreatedAton each update and setsFinishReasononly on the terminal update. It uses one-message lookahead but still emits already-received content and rethrows if the underlying stream faults, so the prior streaming behavior is preserved on the error path.ActivityProcessoris exposed to the test project viaInternalsVisibleTo.Usageis left unset since the Copilot Studio client does not currently surface token usage.