Roundtrip OpenAI Responses reasoning item id for stateless (store=false) encrypted reasoning#7629
Conversation
…e 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 dotnet#7628, microsoft/agent-framework#7067
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 dotnet#7628, microsoft/agent-framework#7067
There was a problem hiding this comment.
Pull request overview
This PR fixes a stateless (store=false) OpenAI Responses resume failure when encrypted reasoning is enabled by ensuring the service-assigned reasoning item id is preserved through streaming coalescing and through chat-history JSON serialization/rehydration.
Changes:
- Introduces an experimental
TextReasoningContent.ItemIdproperty (diagnosticMEAI001) to carry provider-assigned reasoning item identifiers across turns. - Preserves
ItemIdduring streamed content coalescing and restores it when rebuilding outgoingReasoningResponseItempayloads inOpenAIResponsesChatClient. - Adds a regression test that streams reasoning +
encrypted_content, persists/rehydrates history, then asserts the subsequent request includes bothidandencrypted_content.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs | Adds a streaming + persisted-history roundtrip test asserting reasoning id and encrypted_content are preserved on the next request. |
| src/Shared/DiagnosticIds/DiagnosticIds.cs | Adds a new experiments constant for the TextReasoningContent.ItemId experimental API. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs | Captures reasoning item ids into TextReasoningContent while parsing streaming/non-streaming responses and rehydrates them into outgoing ReasoningResponseItem.Id. |
| src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs | Adds experimental ItemId property for provider-assigned reasoning identifiers. |
| src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs | Preserves TextReasoningContent.ItemId when coalescing streamed reasoning deltas into a single content instance. |
| // Simulate persisting and rehydrating the session between turns (as a hosted, human-in-the-loop | ||
| // approval flow does). This drops RawRepresentation (which is [JsonIgnore]), so the reasoning item's | ||
| // id must survive via a serialized field (AdditionalProperties) to roundtrip. |
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 dotnet#7628, microsoft/agent-framework#7067
| // Preserve the provider-assigned reasoning item id (required to roundtrip encrypted reasoning in | ||
| // stateless scenarios). All contents in a coalesced run belong to the same reasoning item, so use | ||
| // the first available id. | ||
| for (int i = start; i < end; i++) | ||
| { |
| // 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)); |
| /// </para> | ||
| /// </remarks> | ||
| [Experimental(DiagnosticIds.Experiments.AIReasoningItemId, UrlFormat = DiagnosticIds.UrlFormat)] | ||
| public string? ItemId { get; set; } |
There was a problem hiding this comment.
I lean towards an additional property since looks like OpenAI is the only provider with an id in their item.
There was a problem hiding this comment.
There was a problem hiding this comment.
Agreed. I checked the other providers and their reasoning blocks only carry a signature/encrypted blob (Anthropic signature, Gemini thought signature, Bedrock reasoningText.signature), which we already model as ProtectedData. The per-item id is specific to OpenAI Responses today. Let's keep it in AdditionalProperties for now and revisit promoting it to the abstraction if/when more providers expose an equivalent id.
|
Updated comment: applying #7629 (comment) will make no need to update the API baseline and the issue raised here should be ignored at that time The new Running { "Member": "string? Microsoft.Extensions.AI.TextReasoningContent.ItemId { get; set; }", "Stage": "Experimental" } |
Problem
When resuming a stateless (
store = false) OpenAI Responses conversation that carries encrypted reasoning (include: ["reasoning.encrypted_content"]),Microsoft.Extensions.AI.OpenAIdrops the reasoning item's service assignedidwhile streaming. On the next request the reconstructed reasoning item has noid, and the Azure AI Foundry project scoped Responses endpoint rejects it withHTTP 400 invalid_payload.This is the root cause behind microsoft/agent-framework#7067 (GPT‑5.6 stateless approval resume failing intermittently). The failure only occurs on turns where the model actually emitted a reasoning item, which is why it looked intermittent.
Key findings:
idis required on the project scoped endpoint (/api/projects/{project}/openai/v1/responses). The resource scoped endpoint (/openai/v1/responses) is lenient about a missingid, which masks the problem when testing directly against it. The summary shape does not matter; theidis the deciding factor.AIContent.RawRepresentationcannot carry theidacross turns because it is[JsonIgnore], so it does not survive the chat history being 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).Tests
Adds
EncryptedReasoning_Streaming_RoundTripsReasoningItemIdtoOpenAIResponseClientTests. It streams a reasoning item (text deltas plusencrypted_contentplusid), coalesces it into history, serializes and rehydrates the history to mimic a persisted session, sends it back, and asserts the outgoing reasoning item still carries itsidandencrypted_content. The serialization step is what makes the test represent the real hosted scenario, and it is why aRawRepresentationonly fix is not enough.The test fails before the fix (all target frameworks) and passes after it. Full
Microsoft.Extensions.AI.OpenAIandMicrosoft.Extensions.AI.Abstractionssuites pass.Two approaches (separate commits)
This PR keeps both approaches as distinct commits so reviewers can compare and pick a direction. The branch tip is the second (first class property) approach.
Commit 1: carry the id in
AdditionalProperties(no public API change).Stashes the reasoning item id in the reasoning content's
AdditionalProperties, which survives both content coalescing and JSON serialization, then restores it on the way out. Contained toOpenAIResponsesChatClient.cs. The read has to accept both astring(in memory) and aJsonElement(after a serialize and rehydrate round trip).Commit 2 (branch tip): first class
TextReasoningContent.ItemIdproperty.Introduces an optional, provider assigned identifier on
TextReasoningContent, marked[Experimental](MEAI001), in the same spirit asProtectedData. This is consistent with other content types that carry their own provider ids (FunctionCallContent.CallId,HostedFileContent.FileId,HostedVectorStoreContent.VectorStoreId). Content coalescing preserves it, and the OpenAI Responses client populates and consumes it. Being a typed, serialized property it survives serialization cleanly and removes the need to special caseJsonElementon read.Considerations addressed by the property approach:
ChatResponseExtensionspreserves the id when merging streamed reasoning deltas, alongside how it already carriesProtectedDatafrom the terminal content.null, exactly asProtectedDatais provider specific today.If reviewers prefer the no API change route, commit 1 can stand alone. If the first class property is preferred, commit 1 is a stepping stone and can be squashed away.
Naming
The new property is named
ItemIdrather thanReasoningItemId: it matches the provider "output item id" terminology, avoids stuttering with theTextReasoningContenttype name, and is consistent with the existingItemIdusage in the image generation path. Happy to rename if maintainers prefer something else.Microsoft Reviewers: Open in CodeFlow