Skip to content

Commit 6a8f8a5

Browse files
crickmanstephentoub
authored andcommitted
Ensure all ResponseItems are yielded in AIContent
1 parent 0f5909e commit 6a8f8a5

3 files changed

Lines changed: 242 additions & 5 deletions

File tree

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,8 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
368368
}
369369

370370
case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate:
371+
// NOTE: When adding a case here, also ensure that same case is added in the generic
372+
// StreamingResponseOutputItemDoneUpdate handler below, if applicable.
371373
switch (outputItemAddedUpdate.Item)
372374
{
373375
case MessageResponseItem mri:
@@ -405,10 +407,6 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
405407
yield return mcpUpdate;
406408
break;
407409

408-
case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolDefinitionListItem mtdli:
409-
yield return CreateUpdate(new AIContent { RawRepresentation = mtdli });
410-
break;
411-
412410
case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallApprovalRequestItem mtcari:
413411
yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel)
414412
{
@@ -439,6 +437,13 @@ outputItemDoneUpdate.Item is MessageResponseItem mri &&
439437
yield return CreateUpdate(annotatedContent);
440438
break;
441439

440+
// Ensures that every fully-formed ResponseItem that wasn't previously handled, either via a dedicated abstraction type or
441+
// via partial deltas handling, is now yielded as part of an AIContent.
442+
case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when
443+
outputItemDoneUpdate.Item is not (MessageResponseItem or ReasoningResponseItem or FunctionCallResponseItem or ImageGenerationCallResponseItem):
444+
yield return CreateUpdate(new AIContent { RawRepresentation = outputItemDoneUpdate.Item });
445+
break;
446+
442447
case StreamingResponseErrorUpdate errorUpdate:
443448
yield return CreateUpdate(new ErrorContent(errorUpdate.Message)
444449
{

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<RootNamespace>Microsoft.Extensions.AI</RootNamespace>
44
<Description>Unit tests for Microsoft.Extensions.AI.OpenAI</Description>
55
<NoWarn>$(NoWarn);S104</NoWarn>
6-
<NoWarn>$(NoWarn);OPENAI001;MEAI001</NoWarn>
6+
<NoWarn>$(NoWarn);OPENAI001;OPENAICUA001;MEAI001</NoWarn>
77
</PropertyGroup>
88

99
<PropertyGroup>

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,238 @@ public async Task BasicRequestResponse_Streaming()
593593
Assert.Equal(36, usage.Details.TotalTokenCount);
594594
}
595595

596+
[Fact]
597+
public async Task MissingAbstractionResponse_NonStreaming()
598+
{
599+
const string Input =
600+
"""
601+
{
602+
"model": "computer-use-preview",
603+
"reasoning":{"summary":"concise"},
604+
"tools": [
605+
{
606+
"type": "computer_use_preview",
607+
"environment": "browser",
608+
"display_width": 1024,
609+
"display_height": 768
610+
}
611+
],
612+
"tool_choice": "auto",
613+
"input": [
614+
{
615+
"type": "message",
616+
"role": "user",
617+
"content": [
618+
{
619+
"type": "input_text",
620+
"text": "Search the web for the temperature today in Fremont"
621+
}
622+
]
623+
}
624+
]
625+
}
626+
""";
627+
628+
const string Output =
629+
"""
630+
{
631+
"id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d",
632+
"object": "response",
633+
"created_at": 1741891428,
634+
"status": "completed",
635+
"error": null,
636+
"incomplete_details": null,
637+
"instructions": null,
638+
"max_output_tokens": 20,
639+
"model": "computer-use-preview-2025-03-11",
640+
"output": [
641+
{
642+
"type": "reasoning",
643+
"id": "rs_67cc...",
644+
"summary": [
645+
{
646+
"type": "summary_text",
647+
"text": "Clicking on the browser address bar."
648+
}
649+
]
650+
},
651+
{
652+
"type": "computer_call",
653+
"id": "cu_67cc...",
654+
"call_id": "call_zw3...",
655+
"action": {
656+
"type": "click",
657+
"button": "left",
658+
"x": 156,
659+
"y": 50
660+
},
661+
"pending_safety_checks": [],
662+
"status": "completed"
663+
}
664+
],
665+
"parallel_tool_calls": true,
666+
"previous_response_id": null,
667+
"reasoning": {
668+
"generate_summary": "concise"
669+
},
670+
"store": true,
671+
"temperature": 1.0,
672+
"text": {
673+
"format": {
674+
"type": "text"
675+
}
676+
},
677+
"tool_choice": "auto",
678+
"tools": [],
679+
"top_p": 1.0,
680+
"usage": {
681+
"input_tokens": 18,
682+
"input_tokens_details": {
683+
"cached_tokens": 0
684+
},
685+
"output_tokens": 53,
686+
"output_tokens_details": {
687+
"reasoning_tokens": 12
688+
},
689+
"total_tokens": 71
690+
},
691+
"user": null,
692+
"metadata": {}
693+
}
694+
""";
695+
696+
using VerbatimHttpHandler handler = new(Input, Output);
697+
using HttpClient httpClient = new(handler);
698+
using IChatClient client = CreateResponseClient(httpClient, "computer-use-preview");
699+
700+
ChatOptions chatOptions = new()
701+
{
702+
Tools = [ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1024, 768).AsAITool()],
703+
RawRepresentationFactory = options => new ResponseCreationOptions
704+
{
705+
ReasoningOptions = new() { ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Concise },
706+
}
707+
};
708+
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Search the web for the temperature today in Fremont")], chatOptions);
709+
Assert.NotNull(response);
710+
711+
Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ResponseId);
712+
Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ConversationId);
713+
Assert.Empty(response.Text);
714+
Assert.Equal("computer-use-preview-2025-03-11", response.ModelId);
715+
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_741_891_428), response.CreatedAt);
716+
Assert.Null(response.FinishReason);
717+
ChatMessage responseMessage = Assert.Single(response.Messages);
718+
Assert.Equal(ChatRole.Assistant, responseMessage.Role);
719+
Assert.Equal(2, responseMessage.Contents.Count);
720+
Assert.Single(responseMessage.Contents, content => content is TextReasoningContent);
721+
AIContent computerUserItem = Assert.Single(responseMessage.Contents, content => content.GetType() == typeof(AIContent));
722+
Assert.NotNull(computerUserItem.RawRepresentation);
723+
Assert.IsType<ComputerCallResponseItem>(computerUserItem.RawRepresentation);
724+
Assert.NotNull(response.Usage);
725+
Assert.Equal(18, response.Usage.InputTokenCount);
726+
Assert.Equal(53, response.Usage.OutputTokenCount);
727+
Assert.Equal(71, response.Usage.TotalTokenCount);
728+
}
729+
730+
[Fact]
731+
public async Task MissingAbstractionResponse_Streaming()
732+
{
733+
const string Input =
734+
"""
735+
{
736+
"model": "computer-use-preview",
737+
"reasoning": {
738+
"summary": "concise"
739+
},
740+
"tools": [
741+
{
742+
"type": "computer_use_preview",
743+
"environment": "browser",
744+
"display_width": 1024,
745+
"display_height": 768
746+
}
747+
],
748+
"tool_choice": "auto",
749+
"input": [
750+
{
751+
"type": "message",
752+
"role": "user",
753+
"content": [
754+
{
755+
"type": "input_text",
756+
"text": "Search the web for the temperature today in Fremont"
757+
}
758+
]
759+
}
760+
],
761+
"stream":true
762+
}
763+
""";
764+
765+
const string Output =
766+
"""
767+
event: response.created
768+
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_0b949080ec8e4b8a006920e0661d00819383ed81438ab11299","object":"response","created_at":1763762278,"status":"in_progress","background":false,"content_filters":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"computer-use-preview-2025-03-11","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","generate_summary":"concise","summary":"concise"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":null,"tools":[{"type":"computer-use-preview-2025-03-11","environment":"browser","display_width":1024,"display_height":768}],"top_logprobs":0,"top_p":1.0,"truncation":"auto","usage":null,"user":null,"metadata":{}}}
769+
770+
event: response.in_progress
771+
data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_0b949080ec8e4b8a006920e0661d00819383ed81438ab11299","object":"response","created_at":1763762278,"status":"in_progress","background":false,"content_filters":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"computer-use-preview-2025-03-11","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","generate_summary":"concise","summary":"concise"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":null,"tools":[{"type":"computer_use_preview","environment":"browser","display_width":1024,"display_height":768}],"top_logprobs":0,"top_p":1.0,"truncation":"auto","usage":null,"user":null,"metadata":{}}}
772+
773+
event: response.output_item.added
774+
data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"type":"computer_call","id":"cu_0b949080ec8e4b8a006920e067a7c0819384d047d21b484357","status":"in_progress","call_id":"call_p7K8YjFwNjqMgkKhSiExgFH6","action":{"type":"screenshot"},"pending_safety_checks":[]}}
775+
776+
event: response.output_item.done
777+
data: {"type":"response.output_item.done","sequence_number":3,"output_index":0,"item":{"type":"computer_call","id":"cu_0b949080ec8e4b8a006920e067a7c0819384d047d21b484357","status":"completed","call_id":"call_p7K8YjFwNjqMgkKhSiExgFH6","action":{"type":"screenshot"},"pending_safety_checks":[]}}
778+
779+
event: response.completed
780+
data: {"type":"response.completed","sequence_number":4,"response":{"id":"resp_0b949080ec8e4b8a006920e0661d00819383ed81438ab11299","object":"response","created_at":1763762278,"status":"completed","background":false,"content_filters":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"computer-use-preview-2025-03-11","output":[{"type":"computer_call","id":"cu_0b949080ec8e4b8a006920e067a7c0819384d047d21b484357","status":"completed","call_id":"call_p7K8YjFwNjqMgkKhSiExgFH6","action":{"type":"screenshot"},"pending_safety_checks":[]}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","generate_summary":"concise","summary":"concise"},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":null,"tools":[{"type":"computer-use-preview-2025-03-11","environment":"browser","display_width":1024,"display_height":768}],"top_logprobs":0,"top_p":1.0,"truncation":"auto","usage":{"input_tokens":18,"input_tokens_details":{"cached_tokens":0},"output_tokens":53,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":71},"user":null,"metadata":{}}}
781+
782+
783+
""";
784+
785+
using VerbatimHttpHandler handler = new(Input, Output);
786+
using HttpClient httpClient = new(handler);
787+
using IChatClient client = CreateResponseClient(httpClient, "computer-use-preview");
788+
789+
ChatOptions chatOptions = new()
790+
{
791+
Tools = [ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1024, 768).AsAITool()],
792+
RawRepresentationFactory = options => new ResponseCreationOptions
793+
{
794+
ReasoningOptions = new() { ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Concise },
795+
}
796+
};
797+
798+
List<ChatResponseUpdate> updates = [];
799+
await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Search the web for the temperature today in Fremont")], chatOptions))
800+
{
801+
updates.Add(update);
802+
}
803+
804+
Assert.Equal(5, updates.Count);
805+
Assert.All(updates, u => Assert.Empty(u.Text));
806+
807+
for (int i = 0; i < updates.Count; i++)
808+
{
809+
Assert.Equal("resp_0b949080ec8e4b8a006920e0661d00819383ed81438ab11299", updates[i].ResponseId);
810+
Assert.Equal("resp_0b949080ec8e4b8a006920e0661d00819383ed81438ab11299", updates[i].ConversationId);
811+
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_763_762_278), updates[i].CreatedAt);
812+
Assert.Equal("computer-use-preview-2025-03-11", updates[i].ModelId);
813+
Assert.Null(updates[i].AdditionalProperties);
814+
Assert.Equal(i >= 3 ? 1 : 0, updates[i].Contents.Count);
815+
Assert.Equal(i >= 4 ? ChatFinishReason.Stop : null, updates[i].FinishReason);
816+
Assert.Null(updates[i].Role);
817+
}
818+
819+
AIContent content = Assert.Single(updates[3].Contents);
820+
var ccri = Assert.IsType<ComputerCallResponseItem>(content.RawRepresentation);
821+
822+
UsageContent usage = Assert.IsType<UsageContent>(Assert.Single(updates[4].Contents));
823+
Assert.Equal(18, usage.Details.InputTokenCount);
824+
Assert.Equal(53, usage.Details.OutputTokenCount);
825+
Assert.Equal(71, usage.Details.TotalTokenCount);
826+
}
827+
596828
[Fact]
597829
public async Task ChatOptions_StrictRespected()
598830
{

0 commit comments

Comments
 (0)