diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 1006f0fdb4f..b3a50672179 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; @@ -45,6 +46,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents OutputItemMessageBuilder? currentMessageBuilder = null; TextContentBuilder? currentTextBuilder = null; StringBuilder? accumulatedText = null; + List? accumulatedAnnotations = null; string? previousMessageId = null; bool hasTerminalEvent = false; var executorItemIds = new Dictionary(); @@ -60,7 +62,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents if (update.RawRepresentation is WorkflowEvent workflowEvent && update.Contents.Count == 0) { // Close any open message builder before emitting workflow items - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -68,6 +70,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents currentTextBuilder = null; currentMessageBuilder = null; accumulatedText = null; + accumulatedAnnotations = null; previousMessageId = null; foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds)) @@ -86,7 +89,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents { if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null) { - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -94,6 +97,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents currentTextBuilder = null; currentMessageBuilder = null; accumulatedText = null; + accumulatedAnnotations = null; } previousMessageId = update.MessageId; @@ -115,6 +119,14 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents yield return currentTextBuilder!.EmitDelta(textContent.Text); } + if (textContent.Annotations is { Count: > 0 }) + { + foreach (var sdkAnnotation in ConvertToSdkAnnotations(textContent.Annotations)) + { + (accumulatedAnnotations ??= []).Add(sdkAnnotation); + } + } + break; } @@ -125,7 +137,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents break; } - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -133,6 +145,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents currentTextBuilder = null; currentMessageBuilder = null; accumulatedText = null; + accumulatedAnnotations = null; previousMessageId = null; var arguments = functionCall.Arguments is not null @@ -149,7 +162,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents case TextReasoningContent reasoningContent: { - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -157,6 +170,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents currentTextBuilder = null; currentMessageBuilder = null; accumulatedText = null; + accumulatedAnnotations = null; previousMessageId = null; var reasoningBuilder = stream.AddOutputItemReasoningItem(); @@ -176,7 +190,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent approvalFunctionCall: { - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -184,6 +198,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents currentTextBuilder = null; currentMessageBuilder = null; accumulatedText = null; + accumulatedAnnotations = null; previousMessageId = null; // The Responses API only standardizes the MCP-flavored approval primitive. @@ -237,7 +252,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents case ErrorContent errorContent: { - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -245,6 +260,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents currentTextBuilder = null; currentMessageBuilder = null; accumulatedText = null; + accumulatedAnnotations = null; previousMessageId = null; hasTerminalEvent = true; @@ -269,7 +285,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents break; } - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -277,6 +293,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents currentTextBuilder = null; currentMessageBuilder = null; accumulatedText = null; + accumulatedAnnotations = null; previousMessageId = null; var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result); @@ -304,7 +321,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents } // Close any remaining open message - foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { yield return evt; } @@ -318,7 +335,8 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents private static IEnumerable CloseCurrentMessage( OutputItemMessageBuilder? messageBuilder, TextContentBuilder? textBuilder, - StringBuilder? accumulatedText) + StringBuilder? accumulatedText, + List? annotations = null) { if (messageBuilder is null) { @@ -329,6 +347,16 @@ private static IEnumerable CloseCurrentMessage( { var finalText = accumulatedText?.ToString() ?? string.Empty; yield return textBuilder.EmitTextDone(finalText); + + // Annotations must be emitted after EmitTextDone and before EmitDone. + if (annotations is not null) + { + foreach (var annotation in annotations) + { + yield return textBuilder.EmitAnnotationAdded(annotation); + } + } + yield return textBuilder.EmitDone(); } @@ -338,6 +366,41 @@ private static IEnumerable CloseCurrentMessage( private static bool IsSameMessage(string? currentId, string? previousId) => currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId; + /// + /// Converts MEAI instances to Responses SDK objects. + /// Only with a URL and at least one + /// with explicit start/end indices is converted; all other shapes are skipped. + /// + private static IEnumerable ConvertToSdkAnnotations(IList annotations) + { + foreach (var ann in annotations) + { + if (ann is not CitationAnnotation citation || citation.Url is null) + { + continue; + } + + var regions = citation.AnnotatedRegions? + .OfType() + .Where(r => r.StartIndex is not null && r.EndIndex is not null) + .ToList(); + + if (regions is not { Count: > 0 }) + { + continue; + } + + foreach (var region in regions) + { + yield return new UrlCitationBody( + citation.Url, + (long)region.StartIndex!.Value, + (long)region.EndIndex!.Value, + citation.Title ?? string.Empty); + } + } + } + private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing) { var inputTokens = details.InputTokenCount ?? 0; diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index f8cc4402cac..5742ef37bca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -1373,6 +1373,218 @@ public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithErrorContent_Emit Assert.Contains(events, e => e is ResponseFailedEvent); } + // === url_citation annotation coverage (N series) === + + // N-01: A TextContent with a url_citation annotation emits ResponseOutputTextAnnotationAddedEvent. + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextWithUrlCitationAnnotation_EmitsAnnotationEventAsync() + { + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var textContent = new MeaiTextContent("Hello") { Annotations = [annotation] }; + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [textContent] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + var annotationEvent = Assert.Single(events.OfType()); + var urlCitation = Assert.IsType(annotationEvent.Annotation); + Assert.Equal(new Uri("https://example.com/doc"), urlCitation.Url); + Assert.Equal("Example Document", urlCitation.Title); + Assert.Equal(0L, urlCitation.StartIndex); + Assert.Equal(5L, urlCitation.EndIndex); + Assert.IsType(events[^1]); + } + + // N-02: Annotation event must appear after the last text delta and before output_item.done. + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextWithAnnotation_AnnotationOrderedAfterDeltaBeforeMessageDoneAsync() + { + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/src"), + Title = "Source", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 20 }] + }; + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("text with citation") { Annotations = [annotation] }] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + var lastDeltaIdx = events.FindLastIndex(e => e is ResponseTextDeltaEvent); + var annotationIdx = events.FindIndex(e => e is ResponseOutputTextAnnotationAddedEvent); + var messageDoneIdx = events.FindIndex(e => e is ResponseOutputItemDoneEvent); + + Assert.True(annotationIdx >= 0, "Expected ResponseOutputTextAnnotationAddedEvent"); + Assert.True(messageDoneIdx >= 0, "Expected ResponseOutputItemDoneEvent"); + Assert.True(lastDeltaIdx < annotationIdx, "Annotation must come after last text delta"); + Assert.True(annotationIdx < messageDoneIdx, "Annotation must come before output_item.done"); + } + + // N-03: Multiple annotations on one TextContent all emit events, in order. + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextWithMultipleAnnotations_EmitsAllAnnotationsAsync() + { + var (stream, _) = CreateTestStream(); + var ann1 = new CitationAnnotation + { + Url = new Uri("https://a.example.com"), + Title = "A", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 10 }] + }; + var ann2 = new CitationAnnotation + { + Url = new Uri("https://b.example.com"), + Title = "B", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 20, EndIndex = 30 }] + }; + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("text") { Annotations = [ann1, ann2] }] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + var annotationEvents = events.OfType().ToList(); + Assert.Equal(2, annotationEvents.Count); + var first = Assert.IsType(annotationEvents[0].Annotation); + Assert.Equal("A", first.Title); + var second = Assert.IsType(annotationEvents[1].Annotation); + Assert.Equal("B", second.Title); + Assert.IsType(events[^1]); + } + + // N-04: Annotations on a TextContent across multiple streaming updates are all accumulated. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationsAcrossMultipleUpdates_AccumulatesAllAsync() + { + var (stream, _) = CreateTestStream(); + var ann1 = new CitationAnnotation + { + Url = new Uri("https://first.example.com"), + Title = "First", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var ann2 = new CitationAnnotation + { + Url = new Uri("https://second.example.com"), + Title = "Second", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 6, EndIndex = 11 }] + }; + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hello") { Annotations = [ann1] }] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent(" world") { Annotations = [ann2] }] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + var annotationEvents = events.OfType().ToList(); + Assert.Equal(2, annotationEvents.Count); + Assert.IsType(events[^1]); + } + + // N-05: A CitationAnnotation without a URL is skipped (no annotation event emitted). + [Fact] + public async Task ConvertUpdatesToEventsAsync_CitationAnnotationWithoutUrl_IsSkippedAsync() + { + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = null, + Title = "No URL", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("text") { Annotations = [annotation] }] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Empty(events.OfType()); + Assert.IsType(events[^1]); + } + + // N-06: A CitationAnnotation without TextSpanAnnotatedRegion is skipped. + [Fact] + public async Task ConvertUpdatesToEventsAsync_CitationAnnotationWithoutRegions_IsSkippedAsync() + { + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com"), + Title = "No Regions", + AnnotatedRegions = null + }; + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("text") { Annotations = [annotation] }] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Empty(events.OfType()); + Assert.IsType(events[^1]); + } + + // N-07: A non-CitationAnnotation in Annotations is silently skipped. + [Fact] + public async Task ConvertUpdatesToEventsAsync_NonCitationAnnotation_IsSkippedAsync() + { + var (stream, _) = CreateTestStream(); + var rawAnnotation = new AIAnnotation(); // base type, not a CitationAnnotation + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("text") { Annotations = [rawAnnotation] }] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Empty(events.OfType()); + Assert.IsType(events[^1]); + } + private sealed class RawToolCallContent : ToolCallContent { public RawToolCallContent(string callId) : base(callId) { }