1515using Microsoft . Agents . AI . Workflows ;
1616using Microsoft . Extensions . AI ;
1717using MeaiTextContent = Microsoft . Extensions . AI . TextContent ;
18+ using OpenAIContainerFileCitationMessageAnnotation = OpenAI . Responses . ContainerFileCitationMessageAnnotation ;
19+ using OpenAIFileCitationMessageAnnotation = OpenAI . Responses . FileCitationMessageAnnotation ;
20+ using OpenAIFilePathMessageAnnotation = OpenAI . Responses . FilePathMessageAnnotation ;
21+ using OpenAIMessageResponseItem = OpenAI . Responses . MessageResponseItem ;
22+ using OpenAIStreamingResponseOutputItemAddedUpdate = OpenAI . Responses . StreamingResponseOutputItemAddedUpdate ;
23+ using OpenAIStreamingResponseOutputItemDoneUpdate = OpenAI . Responses . StreamingResponseOutputItemDoneUpdate ;
24+ using OpenAIStreamingResponseOutputTextAnnotationAddedUpdate = OpenAI . Responses . StreamingResponseOutputTextAnnotationAddedUpdate ;
25+ using OpenAIStreamingResponseOutputTextDeltaUpdate = OpenAI . Responses . StreamingResponseOutputTextDeltaUpdate ;
1826
1927namespace Microsoft . Agents . AI . Foundry . Hosting ;
2028
@@ -100,13 +108,14 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
100108 continue ;
101109 }
102110
111+ string ? currentMessageId = ResolveMessageId ( update ) ;
103112 foreach ( var content in update . Contents )
104113 {
105114 switch ( content )
106115 {
107116 case MeaiTextContent textContent :
108117 {
109- if ( ! IsSameMessage ( update . MessageId , previousMessageId ) && currentMessageBuilder is not null )
118+ if ( ! IsSameMessage ( currentMessageId , previousMessageId ) && currentMessageBuilder is not null )
110119 {
111120 foreach ( var evt in CloseCurrentMessage ( currentMessageBuilder , currentTextBuilder , accumulatedText , accumulatedAnnotations ) )
112121 {
@@ -119,7 +128,10 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
119128 accumulatedAnnotations = null ;
120129 }
121130
122- previousMessageId = update . MessageId ;
131+ if ( currentMessageId is { Length : > 0 } )
132+ {
133+ previousMessageId = currentMessageId ;
134+ }
123135
124136 if ( currentMessageBuilder is null )
125137 {
@@ -138,14 +150,6 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
138150 yield return currentTextBuilder ! . EmitDelta ( textContent . Text ) ;
139151 }
140152
141- if ( textContent . Annotations is { Count : > 0 } )
142- {
143- foreach ( var sdkAnnotation in ConvertToSdkAnnotations ( textContent . Annotations ) )
144- {
145- ( accumulatedAnnotations ??= [ ] ) . Add ( sdkAnnotation ) ;
146- }
147- }
148-
149153 break ;
150154 }
151155
@@ -336,6 +340,27 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
336340 default :
337341 break ;
338342 }
343+
344+ var isTextContent = content is MeaiTextContent ;
345+ var isAnnotationOnlyContentForCurrentMessage =
346+ content . GetType ( ) == typeof ( AIContent ) &&
347+ IsSameAnnotationMessage ( currentMessageId , previousMessageId ) ;
348+
349+ // MEAI OpenAI sends streaming citations in a separate annotation-only AIContent after
350+ // the text deltas. Accumulate them because AgentServer emits annotations only after
351+ // output_text.done, and de-duplicate providers that report the same citation twice.
352+ if ( ( isTextContent || isAnnotationOnlyContentForCurrentMessage )
353+ && content . Annotations is { Count : > 0 }
354+ && currentMessageBuilder is not null )
355+ {
356+ foreach ( var sdkAnnotation in ConvertToSdkAnnotations ( content . Annotations ) )
357+ {
358+ if ( accumulatedAnnotations ? . Any ( existing => AreEquivalentAnnotations ( existing , sdkAnnotation ) ) is not true )
359+ {
360+ ( accumulatedAnnotations ??= [ ] ) . Add ( sdkAnnotation ) ;
361+ }
362+ }
363+ }
339364 }
340365 }
341366
@@ -385,16 +410,93 @@ private static IEnumerable<ResponseStreamEvent> CloseCurrentMessage(
385410 private static bool IsSameMessage ( string ? currentId , string ? previousId ) =>
386411 currentId is not { Length : > 0 } || previousId is not { Length : > 0 } || currentId == previousId ;
387412
413+ private static bool IsSameAnnotationMessage ( string ? currentId , string ? previousId ) =>
414+ currentId is { Length : > 0 }
415+ ? currentId == previousId
416+ : previousId is not { Length : > 0 } ;
417+
418+ private static string ? ResolveMessageId ( AgentResponseUpdate update )
419+ {
420+ if ( update . MessageId is { Length : > 0 } )
421+ {
422+ return update . MessageId ;
423+ }
424+
425+ object ? rawRepresentation = update . RawRepresentation is ChatResponseUpdate chatUpdate
426+ ? chatUpdate . RawRepresentation
427+ : update . RawRepresentation ;
428+
429+ return rawRepresentation switch
430+ {
431+ OpenAIStreamingResponseOutputTextAnnotationAddedUpdate annotationAdded => annotationAdded . ItemId ,
432+ OpenAIStreamingResponseOutputTextDeltaUpdate textDelta => textDelta . ItemId ,
433+ OpenAIStreamingResponseOutputItemAddedUpdate { Item : OpenAIMessageResponseItem message } => message . Id ,
434+ OpenAIStreamingResponseOutputItemDoneUpdate { Item : OpenAIMessageResponseItem message } => message . Id ,
435+ _ => null ,
436+ } ;
437+ }
438+
439+ private static bool AreEquivalentAnnotations ( Annotation left , Annotation right ) =>
440+ ( left , right ) switch
441+ {
442+ ( UrlCitationBody leftUrl , UrlCitationBody rightUrl ) =>
443+ leftUrl . Url == rightUrl . Url &&
444+ leftUrl . StartIndex == rightUrl . StartIndex &&
445+ leftUrl . EndIndex == rightUrl . EndIndex &&
446+ leftUrl . Title == rightUrl . Title ,
447+ ( FileCitationBody leftFile , FileCitationBody rightFile ) =>
448+ leftFile . FileId == rightFile . FileId &&
449+ leftFile . Index == rightFile . Index &&
450+ leftFile . Filename == rightFile . Filename ,
451+ ( ContainerFileCitationBody leftContainerFile , ContainerFileCitationBody rightContainerFile ) =>
452+ leftContainerFile . ContainerId == rightContainerFile . ContainerId &&
453+ leftContainerFile . FileId == rightContainerFile . FileId &&
454+ leftContainerFile . StartIndex == rightContainerFile . StartIndex &&
455+ leftContainerFile . EndIndex == rightContainerFile . EndIndex &&
456+ leftContainerFile . Filename == rightContainerFile . Filename ,
457+ ( FilePath leftFilePath , FilePath rightFilePath ) =>
458+ leftFilePath . FileId == rightFilePath . FileId &&
459+ leftFilePath . Index == rightFilePath . Index ,
460+ _ => false ,
461+ } ;
462+
388463 /// <summary>
389464 /// Converts MEAI <see cref="AIAnnotation"/> instances to Responses SDK <see cref="Annotation"/> objects.
390- /// Only <see cref="CitationAnnotation"/> with a URL and at least one <see cref="TextSpanAnnotatedRegion"/>
391- /// with explicit start/end indices is converted; all other shapes are skipped.
465+ /// Only supported <see cref="CitationAnnotation"/> shapes are converted; all others are skipped.
392466 /// </summary>
393467 private static IEnumerable < Annotation > ConvertToSdkAnnotations ( IList < AIAnnotation > annotations )
394468 {
395469 foreach ( var ann in annotations )
396470 {
397- if ( ann is not CitationAnnotation citation || citation . Url is null )
471+ if ( ann is not CitationAnnotation citation )
472+ {
473+ continue ;
474+ }
475+
476+ if ( citation . RawRepresentation is OpenAIContainerFileCitationMessageAnnotation containerFileCitation )
477+ {
478+ yield return new ContainerFileCitationBody (
479+ containerFileCitation . ContainerId ,
480+ containerFileCitation . FileId ,
481+ containerFileCitation . StartIndex ,
482+ containerFileCitation . EndIndex ,
483+ containerFileCitation . Filename ) ;
484+ continue ;
485+ }
486+
487+ if ( citation . RawRepresentation is OpenAIFileCitationMessageAnnotation fileCitation )
488+ {
489+ yield return new FileCitationBody ( fileCitation . FileId , fileCitation . Index , fileCitation . Filename ) ;
490+ continue ;
491+ }
492+
493+ if ( citation . RawRepresentation is OpenAIFilePathMessageAnnotation filePath )
494+ {
495+ yield return new FilePath ( filePath . FileId , filePath . Index ) ;
496+ continue ;
497+ }
498+
499+ if ( citation . Url is null )
398500 {
399501 continue ;
400502 }
0 commit comments