Summary
When resuming a stateless (store = false) OpenAI Responses conversation that contains encrypted reasoning (include: ["reasoning.encrypted_content"]), Microsoft.Extensions.AI.OpenAI drops the reasoning item's service assigned id while streaming. On resume, the reconstructed reasoning item has no id, and the Azure AI Foundry project scoped Responses endpoint rejects the request with HTTP 400 invalid_payload.
This surfaced as microsoft/agent-framework#7067 (GPT‑5.6 stateless approval resume failing intermittently). The intermittency is explained below: the failure only occurs on turns where the model actually emitted a reasoning item.
Affected versions
Reproduced with Microsoft.Extensions.AI.OpenAI 10.5.2 and confirmed the same code paths on current main (OpenAI SDK 2.12.0). Streaming path only.
Root cause
Two things combine:
- In the streaming path, the encrypted reasoning item is projected to a
TextReasoningContent that carries only ProtectedData (the encrypted_content) and no identifier. The reasoning item id (for example rs_...) is available on the streamed item but is discarded.
- When the chat history is sent back, the outgoing conversion rebuilds a
ReasoningResponseItem from that TextReasoningContent with EncryptedContent set but no Id.
The non streaming path attaches the original item via RawRepresentation, so in memory it can round trip. But AIContent.RawRepresentation is [JsonIgnore], so once the conversation is serialized and rehydrated between turns (for example a hosted human in the loop approval flow where the approval returns in a later request or on a different instance), the id is gone even for the non streaming path.
Why it only reproduces on the project endpoint
A live A/B against the two endpoint shapes, replaying the same reasoning item with and without an id:
| endpoint |
reasoning item has id |
result |
/api/projects/{project}/openai/v1/responses (project scoped) |
no |
400 invalid_payload |
/api/projects/{project}/openai/v1/responses (project scoped) |
yes |
200 |
/openai/v1/responses (resource scoped) |
no |
200 (lenient) |
Summary shape ([] versus [{ "type": "summary_text", "text": "" }]) made no difference. The id is the deciding factor. The resource scoped endpoint is lenient about a missing id, which is why the problem is easy to miss when testing against it directly.
Live rate on gpt-5.6-terra (reasoning effort low), stateless approval resume: 6 of 10 requests failed with encrypted reasoning enabled, 0 of 10 failed with it disabled.
Reproduction
A self contained streaming round trip test (no network) that fails before the fix and passes after it. It streams a reasoning item (text deltas plus encrypted_content plus id), coalesces it into history, serializes and rehydrates the history to mimic a persisted session, sends it back, and asserts the outgoing request's reasoning item still carries its id and encrypted_content.
[Fact]
public async Task EncryptedReasoning_Streaming_RoundTripsReasoningItemId()
{
// 1) stream a response containing a reasoning item with encrypted_content + id, then a message
// 2) chatHistory.AddMessages(updates)
// 3) JsonSerializer round trip chatHistory (simulates a persisted HITL approval session)
// 4) send the history back and assert the outgoing reasoning item has "id" and "encrypted_content"
}
The serialization step in (3) is important: it is what makes the test represent the real hosted scenario, and it is why a RawRepresentation only fix is not sufficient (RawRepresentation is [JsonIgnore]).
Suggested change
Carry the reasoning item id in the reasoning content's AdditionalProperties, which survives both content coalescing (it is cloned from the first content in a coalesced run) and JSON serialization, then restore it on the way out. This is contained to OpenAIResponsesChatClient.cs with no public API change. Note the outgoing read must accept both a string (in memory) and a JsonElement (after a serialize/rehydrate round trip).
Suggested diff (OpenAIResponsesChatClient.cs)
diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
index d3ae6c001f..d3cb511936 100644
--- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
@@ -32,6 +32,12 @@ namespace Microsoft.Extensions.AI;
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal sealed class OpenAIResponsesChatClient : IChatClient
{
+ // AdditionalProperties key used to roundtrip a reasoning item's service-assigned id. Unlike
+ // RawRepresentation, AdditionalProperties survives both content coalescing and JSON serialization of the
+ // chat history, so the id is available when reconstructing the reasoning item for a subsequent request.
+ // Stateless (store=false) resume of encrypted reasoning requires this id or the service rejects the item.
+ private const string ReasoningItemIdKey = "reasoningItemId";
+
// These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept
// a RequestOptions. These should be replaced once a better way to pass RequestOptions is available.
@@ -198,11 +204,7 @@ internal static IEnumerable<ChatMessage> ToChatMessages(IEnumerable<ResponseItem
break;
case ReasoningResponseItem reasoningItem:
- message.Contents.Add(new TextReasoningContent(reasoningItem.GetSummaryText())
- {
- ProtectedData = reasoningItem.EncryptedContent,
- RawRepresentation = outputItem,
- });
+ message.Contents.Add(CreateReasoningContent(reasoningItem.GetSummaryText(), reasoningItem.EncryptedContent, reasoningItem.Id, outputItem));
break;
case FunctionCallResponseItem functionCall:
@@ -475,11 +477,11 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
break;
case StreamingResponseReasoningSummaryTextDeltaUpdate reasoningSummaryTextDeltaUpdate:
- yield return CreateUpdate(new TextReasoningContent(reasoningSummaryTextDeltaUpdate.Delta));
+ yield return CreateUpdate(CreateReasoningContent(reasoningSummaryTextDeltaUpdate.Delta, protectedData: null, reasoningSummaryTextDeltaUpdate.ItemId));
break;
case StreamingResponseReasoningTextDeltaUpdate reasoningTextDeltaUpdate:
- yield return CreateUpdate(new TextReasoningContent(reasoningTextDeltaUpdate.Delta));
+ yield return CreateUpdate(CreateReasoningContent(reasoningTextDeltaUpdate.Delta, protectedData: null, reasoningTextDeltaUpdate.ItemId));
break;
case StreamingResponseImageGenerationCallInProgressUpdate imageGenInProgress:
@@ -595,9 +597,12 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
// so that it can be coalesced with the streamed text deltas and roundtripped.
// Since we may have already yielded reasoning deltas, we explicitly avoid setting
// the RawRepresentation here to avoid duplication, as when roundtripping that
- // raw representation will be preferred.
+ // raw representation will be preferred. The reasoning item's id is stashed in
+ // AdditionalProperties (which survives coalescing and serialization, unlike
+ // RawRepresentation) so that it can be sent back on a subsequent request; stateless
+ // (store=false) resume of encrypted reasoning is rejected by the service without it.
case ReasoningResponseItem rri when rri.EncryptedContent is { Length: > 0 } encryptedContent:
- yield return CreateUpdate(new TextReasoningContent(null) { ProtectedData = encryptedContent });
+ yield return CreateUpdate(CreateReasoningContent(text: null, encryptedContent, rri.Id));
break;
// For ResponseItems where we've already yielded partial deltas for the whole content,
@@ -917,6 +922,24 @@ private static ChatRole AsChatRole(MessageRole? role) =>
_ => ChatRole.Assistant,
};
+ /// <summary>Creates a <see cref="TextReasoningContent"/>, stashing the reasoning item's id (when available)
+ /// in <see cref="AIContent.AdditionalProperties"/> so it roundtrips back to the service.</summary>
+ private static TextReasoningContent CreateReasoningContent(string? text, string? protectedData, string? itemId, object? rawRepresentation = null)
+ {
+ TextReasoningContent content = new(text)
+ {
+ ProtectedData = protectedData,
+ RawRepresentation = rawRepresentation,
+ };
+
+ if (!string.IsNullOrEmpty(itemId))
+ {
+ (content.AdditionalProperties ??= [])[ReasoningItemIdKey] = itemId;
+ }
+
+ return content;
+ }
+
/// <summary>Creates a <see cref="ChatFinishReason"/> from a <see cref="ResponseIncompleteStatusReason"/>.</summary>
private static ChatFinishReason? AsFinishReason(ResponseIncompleteStatusReason? statusReason) =>
statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter :
@@ -1523,10 +1546,29 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera
break;
case TextReasoningContent reasoningContent:
- yield return new ReasoningResponseItem(reasoningContent.Text)
+ ReasoningResponseItem reasoningResponseItem = new(reasoningContent.Text)
{
EncryptedContent = reasoningContent.ProtectedData,
};
+
+ // Restore the reasoning item's id (stashed in AdditionalProperties on the way in)
+ // so that encrypted reasoning roundtrips in stateless (store=false) scenarios,
+ // where the service rejects a reasoning item that is missing its id. The value may
+ // be a string (in-process) or a JsonElement (after the history has been serialized
+ // and rehydrated, e.g. a persisted human-in-the-loop approval session).
+ if (reasoningContent.AdditionalProperties?.TryGetValue(ReasoningItemIdKey, out object? reasoningItemIdValue) is true)
+ {
+ string? reasoningItemId =
+ reasoningItemIdValue as string ??
+ (reasoningItemIdValue is JsonElement { ValueKind: JsonValueKind.String } jsonElement ? jsonElement.GetString() : null);
+
+ if (!string.IsNullOrEmpty(reasoningItemId))
+ {
+ reasoningResponseItem.Id = reasoningItemId;
+ }
+ }
+
+ yield return reasoningResponseItem;
break;
case McpServerToolCallContent mstcc:
Alternative: a first class identifier on TextReasoningContent
An AdditionalProperties entry works and needs no API change, but a cleaner long term option is a first class optional identifier on TextReasoningContent, in the same spirit as ProtectedData. This is consistent with existing content types that carry their own provider ids (FunctionCallContent.CallId, HostedFileContent.FileId, HostedVectorStoreContent.VectorStoreId, ToolCallContent/ImageGenerationToolCallContent item ids).
Considerations for making it apply to all scenarios:
- Serialization: a normal property serializes and rehydrates cleanly, avoiding the
JsonElement handling the AdditionalProperties approach needs.
- Coalescing:
ChatResponseExtensions merges streamed reasoning deltas. The merge already takes ProtectedData from the last (terminal) content in a run; a first class id would need the same treatment so it survives coalescing.
- Cross provider applicability: OpenAI Responses uses a reasoning item
id. Providers that do not have one leave it null, exactly as ProtectedData is provider specific today. So an optional identifier is generally applicable without forcing semantics on providers that do not need it.
If the maintainers prefer the first class property, the interim AdditionalProperties fix above can be a stepping stone, or the property can be introduced directly and the OpenAI client updated to populate and consume it.
References
Summary
When resuming a stateless (
store = false) OpenAI Responses conversation that contains encrypted reasoning (include: ["reasoning.encrypted_content"]),Microsoft.Extensions.AI.OpenAIdrops the reasoning item's service assignedidwhile streaming. On resume, the reconstructed reasoning item has noid, and the Azure AI Foundry project scoped Responses endpoint rejects the request withHTTP 400 invalid_payload.This surfaced as microsoft/agent-framework#7067 (GPT‑5.6 stateless approval resume failing intermittently). The intermittency is explained below: the failure only occurs on turns where the model actually emitted a reasoning item.
Affected versions
Reproduced with
Microsoft.Extensions.AI.OpenAI10.5.2 and confirmed the same code paths on currentmain(OpenAI SDK 2.12.0). Streaming path only.Root cause
Two things combine:
TextReasoningContentthat carries onlyProtectedData(theencrypted_content) and no identifier. The reasoning itemid(for examplers_...) is available on the streamed item but is discarded.ReasoningResponseItemfrom thatTextReasoningContentwithEncryptedContentset but noId.The non streaming path attaches the original item via
RawRepresentation, so in memory it can round trip. ButAIContent.RawRepresentationis[JsonIgnore], so once the conversation is serialized and rehydrated between turns (for example a hosted human in the loop approval flow where the approval returns in a later request or on a different instance), theidis gone even for the non streaming path.Why it only reproduces on the project endpoint
A live A/B against the two endpoint shapes, replaying the same reasoning item with and without an
id:id/api/projects/{project}/openai/v1/responses(project scoped)invalid_payload/api/projects/{project}/openai/v1/responses(project scoped)/openai/v1/responses(resource scoped)Summary shape (
[]versus[{ "type": "summary_text", "text": "" }]) made no difference. Theidis the deciding factor. The resource scoped endpoint is lenient about a missingid, which is why the problem is easy to miss when testing against it directly.Live rate on
gpt-5.6-terra(reasoning effort low), stateless approval resume: 6 of 10 requests failed with encrypted reasoning enabled, 0 of 10 failed with it disabled.Reproduction
A self contained streaming round trip test (no network) that fails before the fix and passes after it. It streams a reasoning item (text deltas plus
encrypted_contentplusid), coalesces it into history, serializes and rehydrates the history to mimic a persisted session, sends it back, and asserts the outgoing request's reasoning item still carries itsidandencrypted_content.The serialization step in (3) is important: it is what makes the test represent the real hosted scenario, and it is why a
RawRepresentationonly fix is not sufficient (RawRepresentationis[JsonIgnore]).Suggested change
Carry the reasoning item
idin the reasoning content'sAdditionalProperties, which survives both content coalescing (it is cloned from the first content in a coalesced run) and JSON serialization, then restore it on the way out. This is contained toOpenAIResponsesChatClient.cswith no public API change. Note the outgoing read must accept both astring(in memory) and aJsonElement(after a serialize/rehydrate round trip).Suggested diff (OpenAIResponsesChatClient.cs)
Alternative: a first class identifier on
TextReasoningContentAn
AdditionalPropertiesentry works and needs no API change, but a cleaner long term option is a first class optional identifier onTextReasoningContent, in the same spirit asProtectedData. This is consistent with existing content types that carry their own provider ids (FunctionCallContent.CallId,HostedFileContent.FileId,HostedVectorStoreContent.VectorStoreId,ToolCallContent/ImageGenerationToolCallContentitem ids).Considerations for making it apply to all scenarios:
JsonElementhandling theAdditionalPropertiesapproach needs.ChatResponseExtensionsmerges streamed reasoning deltas. The merge already takesProtectedDatafrom the last (terminal) content in a run; a first class id would need the same treatment so it survives coalescing.id. Providers that do not have one leave itnull, exactly asProtectedDatais provider specific today. So an optional identifier is generally applicable without forcing semantics on providers that do not need it.If the maintainers prefer the first class property, the interim
AdditionalPropertiesfix above can be a stepping stone, or the property can be introduced directly and the OpenAI client updated to populate and consume it.References