ai newsDi., 20. Okt.
EilmeldungMicrosoft adds EU access oversight and offline AI to its sovereign cloud

3 Places Your .NET Chat Code Quietly Drops OpenAI's Encrypted Reasoning

The bug I want to talk about doesn't throw. Nothing turns red in the logs and no test fails. A reasoning model answers the first question well, and on the second turn it acts as if the first turn never happened. You blame the prompt, then the temperature, then the model. The real cause is usually o

dev.toDienstag 13:59 路 20.10.7 Min. Lesezeit
Bild: dev.to

The bug I want to talk about doesn't throw. Nothing turns red in the logs and no test fails. A reasoning model answers the first question well, and on the second turn it acts as if the first turn never happened. You blame the prompt, then the temperature, then the model. The real cause is usually one line in your own chat loop.

Here's what's going on. When you call OpenAI's Responses API through Microsoft.Extensions.AI with a reasoning model, nothing stored on the server ( store=false, or zero data retention) and encrypted reasoning requested, the response carries an encrypted reasoning item. In.NET it shows up as a TextReasoningContent part with a ProtectedData string and an item id in AdditionalProperties["reasoningItemId"]. The service doesn't remember it for you. If you don't send it back on the next turn, the model can't continue its reasoning chain.

I built a small.NET 10 sample on Microsoft.Extensions.AI 10.11 to pin this down. It runs against an offline fake model that emits real TextReasoningContent parts and reports in its answer how many encrypted items it got back ( priorProtected=N ), so you can see the bug without an API key. It has 38 tests (29 unit, 9 integration). Below are the three places I've seen the reasoning disappear, with a test for each. The complete project is in Tech Skill Builder.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.

Each assistant message from a reasoning call can hold more than text. These are the parts that matter here:

The real client comes from the Responses adapter, not the Chat Completions one:

// Experimental (OPENAI001); the sample suppresses it once in Directory.Build.props IChatClient client = new OpenAIClient ( apiKey ). GetResponsesClient (). AsIChatClient ( modelId ); Enter fullscreen mode Exit fullscreen mode Keep that table in mind. All three leaks below come down to one thing: something kept TextContent and threw away the rest.

This is the line almost every chat tutorial teaches:

history. Add ( new ChatMessage ( ChatRole. Assistant, response. Text )); // drops TextReasoningContent Enter fullscreen mode Exit fullscreen mode response.Text is a convenience view over the text parts only. The reasoning part never makes it into your history. Here's what the sample's CLI prints when it does exactly this and then asks a follow-up:

Response had ProtectedData: True History has ProtectedData after text-only append: False Follow-up text: Offline (Medium, stateless, priorProtected=0): answered "Follow-up". Enter fullscreen mode Exit fullscreen mode The response had the encrypted reasoning. The history didn't. The follow-up went out with zero prior reasoning items.

The fix is to append the whole response: history.AddMessages(response). In the sample I put that behind a tiny helper, ReasoningHistory.AppendResponse(history, response), so nobody "simplifies" it back later. The test shows the failure on purpose:

[ Fact ] public async Task TextOnly_Append_Loses_ProtectedData () { var client = new OfflineReasoningChatClient (); var history = new List < ChatMessage > { new ( ChatRole. User, "lose me" ) }; var response = await client. GetResponseAsync ( history, ReasonOpts ()); history.

Add ( new ChatMessage ( ChatRole. Assistant, response. Text )); Assert.

False ( ReasoningHistory. HasProtectedReasoning ( history )); Assert. True ( ReasoningHistory.

LooksLikeTextOnlyAppend ( history [^ 1 ])); } Enter fullscreen mode Exit fullscreen mode Its twin, AppendResponse_Preserves_ProtectedData, does the same with the helper and asserts the opposite. The sample API even has a deliberately wrong endpoint, POST /api/chat/turn/wrong, that appends only the text. Its response reports hasProtectedInResponse: true and hasProtectedInHistory: false, which makes the bug easy to demo to a team.

Streaming makes this worse, because the live output looks complete. Updates arrive roughly in this order: reasoning summary chunks, then answer text chunks, then a final update with the encrypted part and no visible text at all. The sample's fake mirrors that order, and a unit test checks it so the fake can't drift.

The usual UI loop builds a string from the text chunks and saves that string as the assistant message. Everything the user saw is there. The encrypted part isn't, because it never had any text.

The fix: keep every update, merge them back into a response, and append that:

var updates = new List < ChatResponseUpdate >(); await foreach ( var update in chatClient. GetStreamingResponseAsync ( history, chatOptions, cancellationToken )) { updates. Add ( update ); yield return update; // the UI still gets every chunk live } ReasoningHistory.

AppendResponse ( history, updates. ToChatResponse ()); // options, the user message and cancellation plumbing: full StreamAsync in the complete project Enter fullscreen mode Exit fullscreen mode ToChatResponse() merges the chunks into messages, so the summary text and the ProtectedData from the last update end up in history. The demo's last check after a stream prints After stream, history reasoning parts: 2, protected: True.

The test, StreamAsync_Coalesces_Into_History_With_ProtectedData, drains the stream and asserts that HasProtectedReasoning(history) is true.

Sooner or later, chat history leaves memory. It goes to a cache, a database, or a JSON column, because there's a human-in-the-loop pause or a second server. That trip has two ways to lose reasoning:

return value switch { string s => s, JsonElement { ValueKind: JsonValueKind. String } je => je. GetString (), _ => value. ToString (), }; // null checks and the TryGetValue lookup: full ReasoningHistory.GetReasoningItemId in the complete project Enter fullscreen mode Exit fullscreen mode The test, GetReasoningItemId_Reads_JsonElement_After_Serialization, serializes the dictionary, deserializes it into a fresh TextReasoningContent and checks that the id still reads back as "rsn_json".

To be clear about scope: the sample's SessionStore is in-memory (it says "demo only"), so there's no database round trip in the project. What it does test is the piece that bites first, which is the dictionary going through JSON.

Once you keep ProtectedData around, the next risk is that it ends up somewhere it shouldn't, like a log index that keeps everything for a year. The sample's ReasoningAuditChatClient is a DelegatingChatClient that logs effort, token usage (including ReasoningTokenCount ) and a truncated reasoning summary, and nothing else. It also has a deliberately petty tripwire: AssertNoProtectedDataInLogPayload throws if a payload about to be logged contains the fake's enc_ prefix. It sounds paranoid until someone adds "just log the whole response" during an incident.

If you only add one test, make it a real two-turn conversation against a fake that reports what it received:

[ Fact ] public async Task MultiTurn_Preserves_Encrypted_Items () { var svc = CreateService ( stateless: true ); var history = new List < ChatMessage >(); await svc. SendAsync ( history, "turn one", ReasoningEffort. Low ); var turn2 = await svc. SendAsync ( history, "turn two", ReasoningEffort. High ); Assert. Contains ( "priorProtected=1", turn2. Text, StringComparison. Ordinal ); Assert. Equal ( 2, ReasoningHistory. CountReasoningParts ( history )); } Enter fullscreen mode Exit fullscreen mode Whichever of the three leaks you introduce later, turn two comes back with priorProtected=0 and this test fails.

All 38 tests run against the offline fake. It produces the same content types the OpenAI adapter does, but it isn't OpenAI. CI never makes a live call. For a truly stateless call on the wire you also need to turn stored output off and ask for the encrypted reasoning to be included (the project README points to ChatOptions.RawRepresentationFactory and CreateResponseOptions for that). The sample doesn't run that path, so check the current OpenAI.NET docs before you rely on it. And if you use stored responses with a ConversationId, the server keeps the earlier items and Leak 1 hurts less. You still want the full messages in your own history for replay and audit.

The snippets above show the shape. The complete, working version is in Tech Skill Builder: the Core library (history helpers, the chat service for normal and streaming turns, the reasoning audit middleware and the offline reasoning client), a minimal API with both the correct and the deliberately wrong turn endpoints, a CLI demo and all 38 tests, plus a step-by-step PDF guide. It runs with no API key. When you're ready for real calls, set ReasoningChat:Provider to openai and supply your key through user-secrets or an environment variable.

Tech Skill Builder gives you complete, working, tested.NET projects with full source code and step-by-step PDF guides, with new ones added regularly. Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. 馃憠 Join Tech Skill Builder

Where does your chat history live between turns, and have you checked that the reasoning parts survive the trip?

Quelle: dev.to (dev.to). Original 枚ffnen