Skip to content

Commit 49b5c83

Browse files
Roundtrip OpenAI Responses reasoning item id for stateless (store=false) encrypted reasoning (#7629)
* Fix OpenAI Responses encrypted reasoning id round-trip for store=false resume The streaming path dropped the reasoning item's service assigned id, so on a stateless (store=false) resume the reconstructed reasoning item had no id and the Azure AI Foundry project scoped Responses endpoint rejected the request with HTTP 400 invalid_payload. RawRepresentation could not carry the id because it is [JsonIgnore] and does not survive session serialization between turns. Carry the reasoning item id in the reasoning content's AdditionalProperties, which survives both content coalescing and JSON serialization, and restore it on the outgoing request (handling both string and JsonElement values). Adds a streaming round-trip test that serializes and rehydrates the history to model a persisted human in the loop approval session. Refs #7628, microsoft/agent-framework#7067 * Use first-class TextReasoningContent.ItemId to roundtrip reasoning id Replaces the AdditionalProperties based carrier from the previous commit with a first class optional TextReasoningContent.ItemId property, marked [Experimental] (MEAI001). Content coalescing preserves it, and the OpenAI Responses client populates and consumes it, so the reasoning item id survives both coalescing and JSON serialization of the chat history and is sent back on stateless (store=false) resume. The typed, serializable property also removes the need to special case JsonElement values when reading the id back. The AdditionalProperties approach is kept in history (previous commit) for backtrack. Refs #7628, microsoft/agent-framework#7067 * Fix stale test comment to reference TextReasoningContent.ItemId The roundtrip test comment still referenced the AdditionalProperties carrier from the earlier commit; the current implementation roundtrips the reasoning item id via the TextReasoningContent.ItemId property. Addresses PR review feedback. Refs #7628, microsoft/agent-framework#7067 * Revert "Fix stale test comment to reference TextReasoningContent.ItemId" This reverts commit bc42d22. * Revert "Use first-class TextReasoningContent.ItemId to roundtrip reasoning id" This reverts commit 6fe9943. * Use named arguments for CreateReasoningContent encrypted-content call Pass protectedData and itemId by name at the streaming encrypted-content call site to avoid misreading the positional encrypted-content argument. No behavior change. --------- Co-authored-by: Tarek Mahmoud Sayed <tarekms@microsoft.com>
1 parent 10133db commit 49b5c83

2 files changed

Lines changed: 158 additions & 10 deletions

File tree

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

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ namespace Microsoft.Extensions.AI;
3232
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
3333
internal sealed class OpenAIResponsesChatClient : IChatClient
3434
{
35+
// AdditionalProperties key used to roundtrip a reasoning item's service-assigned id. Unlike
36+
// RawRepresentation, AdditionalProperties survives both content coalescing and JSON serialization of the
37+
// chat history, so the id is available when reconstructing the reasoning item for a subsequent request.
38+
// Stateless (store=false) resume of encrypted reasoning requires this id or the service rejects the item.
39+
private const string ReasoningItemIdKey = "reasoningItemId";
40+
3541
// These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept
3642
// a RequestOptions. These should be replaced once a better way to pass RequestOptions is available.
3743

@@ -198,11 +204,7 @@ internal static IEnumerable<ChatMessage> ToChatMessages(IEnumerable<ResponseItem
198204
break;
199205

200206
case ReasoningResponseItem reasoningItem:
201-
message.Contents.Add(new TextReasoningContent(reasoningItem.GetSummaryText())
202-
{
203-
ProtectedData = reasoningItem.EncryptedContent,
204-
RawRepresentation = outputItem,
205-
});
207+
message.Contents.Add(CreateReasoningContent(reasoningItem.GetSummaryText(), reasoningItem.EncryptedContent, reasoningItem.Id, outputItem));
206208
break;
207209

208210
case FunctionCallResponseItem functionCall:
@@ -475,11 +477,11 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
475477
break;
476478

477479
case StreamingResponseReasoningSummaryTextDeltaUpdate reasoningSummaryTextDeltaUpdate:
478-
yield return CreateUpdate(new TextReasoningContent(reasoningSummaryTextDeltaUpdate.Delta));
480+
yield return CreateUpdate(CreateReasoningContent(reasoningSummaryTextDeltaUpdate.Delta, protectedData: null, reasoningSummaryTextDeltaUpdate.ItemId));
479481
break;
480482

481483
case StreamingResponseReasoningTextDeltaUpdate reasoningTextDeltaUpdate:
482-
yield return CreateUpdate(new TextReasoningContent(reasoningTextDeltaUpdate.Delta));
484+
yield return CreateUpdate(CreateReasoningContent(reasoningTextDeltaUpdate.Delta, protectedData: null, reasoningTextDeltaUpdate.ItemId));
483485
break;
484486

485487
case StreamingResponseImageGenerationCallInProgressUpdate imageGenInProgress:
@@ -595,9 +597,12 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
595597
// so that it can be coalesced with the streamed text deltas and roundtripped.
596598
// Since we may have already yielded reasoning deltas, we explicitly avoid setting
597599
// the RawRepresentation here to avoid duplication, as when roundtripping that
598-
// raw representation will be preferred.
600+
// raw representation will be preferred. The reasoning item's id is stashed in
601+
// AdditionalProperties (which survives coalescing and serialization, unlike
602+
// RawRepresentation) so that it can be sent back on a subsequent request; stateless
603+
// (store=false) resume of encrypted reasoning is rejected by the service without it.
599604
case ReasoningResponseItem rri when rri.EncryptedContent is { Length: > 0 } encryptedContent:
600-
yield return CreateUpdate(new TextReasoningContent(null) { ProtectedData = encryptedContent });
605+
yield return CreateUpdate(CreateReasoningContent(text: null, protectedData: encryptedContent, itemId: rri.Id));
601606
break;
602607

603608
// For ResponseItems where we've already yielded partial deltas for the whole content,
@@ -917,6 +922,24 @@ private static ChatRole AsChatRole(MessageRole? role) =>
917922
_ => ChatRole.Assistant,
918923
};
919924

925+
/// <summary>Creates a <see cref="TextReasoningContent"/>, stashing the reasoning item's id (when available)
926+
/// in <see cref="AIContent.AdditionalProperties"/> so it roundtrips back to the service.</summary>
927+
private static TextReasoningContent CreateReasoningContent(string? text, string? protectedData, string? itemId, object? rawRepresentation = null)
928+
{
929+
TextReasoningContent content = new(text)
930+
{
931+
ProtectedData = protectedData,
932+
RawRepresentation = rawRepresentation,
933+
};
934+
935+
if (!string.IsNullOrEmpty(itemId))
936+
{
937+
(content.AdditionalProperties ??= [])[ReasoningItemIdKey] = itemId;
938+
}
939+
940+
return content;
941+
}
942+
920943
/// <summary>Creates a <see cref="ChatFinishReason"/> from a <see cref="ResponseIncompleteStatusReason"/>.</summary>
921944
private static ChatFinishReason? AsFinishReason(ResponseIncompleteStatusReason? statusReason) =>
922945
statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter :
@@ -1523,10 +1546,29 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera
15231546
break;
15241547

15251548
case TextReasoningContent reasoningContent:
1526-
yield return new ReasoningResponseItem(reasoningContent.Text)
1549+
ReasoningResponseItem reasoningResponseItem = new(reasoningContent.Text)
15271550
{
15281551
EncryptedContent = reasoningContent.ProtectedData,
15291552
};
1553+
1554+
// Restore the reasoning item's id (stashed in AdditionalProperties on the way in)
1555+
// so that encrypted reasoning roundtrips in stateless (store=false) scenarios,
1556+
// where the service rejects a reasoning item that is missing its id. The value may
1557+
// be a string (in-process) or a JsonElement (after the history has been serialized
1558+
// and rehydrated, e.g. a persisted human-in-the-loop approval session).
1559+
if (reasoningContent.AdditionalProperties?.TryGetValue(ReasoningItemIdKey, out object? reasoningItemIdValue) is true)
1560+
{
1561+
string? reasoningItemId =
1562+
reasoningItemIdValue as string ??
1563+
(reasoningItemIdValue is JsonElement { ValueKind: JsonValueKind.String } jsonElement ? jsonElement.GetString() : null);
1564+
1565+
if (!string.IsNullOrEmpty(reasoningItemId))
1566+
{
1567+
reasoningResponseItem.Id = reasoningItemId;
1568+
}
1569+
}
1570+
1571+
yield return reasoningResponseItem;
15301572
break;
15311573

15321574
case McpServerToolCallContent mstcc:

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,95 @@ public async Task ReasoningTextDelta_Streaming_PreservesEncryptedContent()
576576
Assert.Equal("secret-encrypted-data-abc123", coalescedReasoning.ProtectedData);
577577
}
578578

579+
[Fact]
580+
public async Task EncryptedReasoning_Streaming_RoundTripsReasoningItemId()
581+
{
582+
// Streamed response containing a reasoning item (text deltas + encrypted_content + id),
583+
// followed by an assistant message. Mirrors a stateless (store=false) turn that included
584+
// reasoning.encrypted_content.
585+
const string FirstResponse = """
586+
event: response.created
587+
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_reasoning123","object":"response","created_at":1756752900,"status":"in_progress","model":"o4-mini-2025-04-16","output":[],"reasoning":{"effort":"medium"}}}
588+
589+
event: response.output_item.added
590+
data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"rs_reasoning123","type":"reasoning","text":""}}
591+
592+
event: response.reasoning_text.delta
593+
data: {"type":"response.reasoning_text.delta","sequence_number":2,"item_id":"rs_reasoning123","output_index":0,"delta":"First, "}
594+
595+
event: response.reasoning_text.delta
596+
data: {"type":"response.reasoning_text.delta","sequence_number":3,"item_id":"rs_reasoning123","output_index":0,"delta":"let's analyze the problem."}
597+
598+
event: response.reasoning_text.done
599+
data: {"type":"response.reasoning_text.done","sequence_number":4,"item_id":"rs_reasoning123","output_index":0,"text":"First, let's analyze the problem."}
600+
601+
event: response.output_item.done
602+
data: {"type":"response.output_item.done","sequence_number":5,"output_index":0,"item":{"id":"rs_reasoning123","type":"reasoning","text":"First, let's analyze the problem.","encrypted_content":"secret-encrypted-data-abc123"}}
603+
604+
event: response.output_item.added
605+
data: {"type":"response.output_item.added","sequence_number":6,"output_index":1,"item":{"id":"msg_reasoning123","type":"message","status":"in_progress","content":[],"role":"assistant"}}
606+
607+
event: response.output_text.delta
608+
data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_reasoning123","output_index":1,"content_index":0,"delta":"The solution is 42."}
609+
610+
event: response.output_item.done
611+
data: {"type":"response.output_item.done","sequence_number":8,"output_index":1,"item":{"id":"msg_reasoning123","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"The solution is 42."}],"role":"assistant"}}
612+
613+
event: response.completed
614+
data: {"type":"response.completed","sequence_number":9,"response":{"id":"resp_reasoning123","object":"response","created_at":1756752900,"status":"completed","model":"o4-mini-2025-04-16","output":[{"id":"rs_reasoning123","type":"reasoning","text":"First, let's analyze the problem.","encrypted_content":"secret-encrypted-data-abc123"},{"id":"msg_reasoning123","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"The solution is 42."}],"role":"assistant"}],"usage":{"input_tokens":10,"output_tokens":25,"total_tokens":35}}}
615+
616+
""";
617+
618+
const string SecondResponse = """
619+
{"id":"resp_followup","object":"response","created_at":1756752901,"status":"completed","model":"o4-mini-2025-04-16","output":[{"id":"msg_followup","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Confirmed."}],"role":"assistant"}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}
620+
""";
621+
622+
using CapturingHttpHandler handler = new([FirstResponse, SecondResponse]);
623+
using HttpClient httpClient = new(handler);
624+
using IChatClient client = CreateResponseClient(httpClient, "o4-mini");
625+
626+
// Turn 1: stream a response with encrypted reasoning and collect the history.
627+
List<ChatResponseUpdate> updates = [];
628+
await foreach (var update in client.GetStreamingResponseAsync("Solve this problem step by step.", new()
629+
{
630+
RawRepresentationFactory = _ => new CreateResponseOptions
631+
{
632+
StoredOutputEnabled = false,
633+
ReasoningOptions = new() { ReasoningEffortLevel = ResponseReasoningEffortLevel.Medium },
634+
},
635+
}))
636+
{
637+
updates.Add(update);
638+
}
639+
640+
List<ChatMessage> chatHistory = [];
641+
chatHistory.AddMessages(updates);
642+
643+
// Simulate persisting and rehydrating the session between turns (as a hosted, human-in-the-loop
644+
// approval flow does). This drops RawRepresentation (which is [JsonIgnore]), so the reasoning item's
645+
// id must survive via a serialized field (AdditionalProperties) to roundtrip.
646+
string serializedHistory = JsonSerializer.Serialize(chatHistory, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(List<ChatMessage>)));
647+
chatHistory = (List<ChatMessage>)JsonSerializer.Deserialize(serializedHistory, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(List<ChatMessage>)))!;
648+
chatHistory.Add(new ChatMessage(ChatRole.User, "Are you sure?"));
649+
650+
// Turn 2: send the history back. The reasoning item must roundtrip with its id, otherwise
651+
// a stateless (store=false) service rejects the request (see dotnet/extensions issue and
652+
// microsoft/agent-framework#7067).
653+
await client.GetResponseAsync(chatHistory, new()
654+
{
655+
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false },
656+
});
657+
658+
Assert.Equal(2, handler.RequestBodies.Count);
659+
660+
using JsonDocument secondRequest = JsonDocument.Parse(handler.RequestBodies[1]);
661+
JsonElement reasoningItem = secondRequest.RootElement.GetProperty("input").EnumerateArray()
662+
.Single(item => item.TryGetProperty("type", out var t) && t.GetString() == "reasoning");
663+
664+
Assert.Equal("rs_reasoning123", reasoningItem.GetProperty("id").GetString());
665+
Assert.Equal("secret-encrypted-data-abc123", reasoningItem.GetProperty("encrypted_content").GetString());
666+
}
667+
579668
[Fact]
580669
public async Task BasicRequestResponse_Streaming()
581670
{
@@ -6779,6 +6868,23 @@ private static IChatClient CreateResponseClient(HttpClient httpClient, string mo
67796868
.GetResponsesClient()
67806869
.AsIChatClient(modelId);
67816870

6871+
// Returns the supplied responses in order, one per request, capturing each request body.
6872+
private sealed class CapturingHttpHandler(IReadOnlyList<string> responses) : HttpMessageHandler
6873+
{
6874+
private int _index;
6875+
6876+
public List<string> RequestBodies { get; } = [];
6877+
6878+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
6879+
{
6880+
#pragma warning disable CA2016 // ReadAsStringAsync(CancellationToken) is unavailable on all target frameworks
6881+
RequestBodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync().ConfigureAwait(false));
6882+
#pragma warning restore CA2016
6883+
string body = responses[Math.Min(_index++, responses.Count - 1)];
6884+
return new HttpResponseMessage { Content = new StringContent(body) };
6885+
}
6886+
}
6887+
67826888
private static string ResponseStatusToRequestValue(ResponseStatus status)
67836889
{
67846890
if (status == ResponseStatus.InProgress)

0 commit comments

Comments
 (0)