Skip to content

.NET: Populate AgentResponse metadata in CopilotStudioAgent#6791

Open
anneheartrecord wants to merge 1 commit into
microsoft:mainfrom
anneheartrecord:feat/copilotstudio-response-metadata
Open

.NET: Populate AgentResponse metadata in CopilotStudioAgent#6791
anneheartrecord wants to merge 1 commit into
microsoft:mainfrom
anneheartrecord:feat/copilotstudio-response-metadata

Conversation

@anneheartrecord

@anneheartrecord anneheartrecord commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Closes #6790.

CopilotStudioAgent only set AgentId/ResponseId on the non-streaming AgentResponse, leaving CreatedAt, FinishReason, RawRepresentation and AdditionalProperties unset; the streaming path was also missing CreatedAt and FinishReason. This brings the provider in line with the others (e.g. A2AAgent) and clears the existing TODOs in the source.

Changes:

  • ActivityProcessor maps the activity Timestamp to ChatMessage.CreatedAt and copies IActivity.Properties into ChatMessage.AdditionalProperties (left null when there are none).
  • Non-streaming RunAsync populates CreatedAt, FinishReason (Stop), RawRepresentation and AdditionalProperties from the final message.
  • Streaming RunStreamingAsync carries CreatedAt on each update and sets FinishReason only 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.
  • Added unit tests covering the activity-to-message mapping, response-level metadata, the terminal finish reason, single-message streams, and the mid-stream fault case. ActivityProcessor is exposed to the test project via InternalsVisibleTo.

Usage is left unset since the Copilot Studio client does not currently surface token usage.

Copilot AI review requested due to automatic review settings June 29, 2026 08:15
@moonbox3 moonbox3 added the .NET Usage: [Issues, PRs], Target: .Net label Jun 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Timestamp to ChatMessage.CreatedAt and propagate activity Properties to ChatMessage.AdditionalProperties (null when empty).
  • Populate non-streaming AgentResponse metadata (CreatedAt, FinishReason, RawRepresentation, AdditionalProperties) from the final message.
  • Update streaming projection to carry CreatedAt on each update and set FinishReason only 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.

Comment on lines +166 to +199
// 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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +201 to 204
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 };
@anneheartrecord

Copy link
Copy Markdown
Contributor Author

@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.

@giles17

giles17 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

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.
@anneheartrecord anneheartrecord force-pushed the feat/copilotstudio-response-metadata branch from 39d2615 to c22e1c8 Compare July 14, 2026 04:58
@anneheartrecord

Copy link
Copy Markdown
Contributor Author

@giles17 Done. Rebased onto latest main — the build failure was Microsoft.OpenApi 2.0.0 (NU1903) in an unrelated sample project, which main has already remediated by pinning 2.7.5. Addressed the Copilot comments too: guarded DisposeAsync() in the streaming path so it can't mask the original stream fault, and disposed the JsonDocument in the test via using + Clone(). Left a note on the third about the streaming lookahead. Green locally — the CopilotStudio project builds with --warnaserror and all 16 CopilotStudio tests pass. Ready for another look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

.NET Usage: [Issues, PRs], Target: .Net

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [Feature]: Populate AgentResponse metadata in CopilotStudioAgent

4 participants