fix: a provider's own handle on its thought survives the round-trip #11

Merged
bjoern merged 2 commits from fix/reasoning-detail-extension-data into main 2026-07-30 13:31:54 +02:00
Member

Closes #10, which I measured against live google/gemini-3.5-flash before writing this. The evidence is on the issue; the short version is that #9 stopped the crash and left the cause running.

What the measurement showed

Two identical five-round tool-calling conversations, differing only in whether the echoed assistant turn keeps reasoning.encrypted.id:

arm payload-less segment came back still calling tools at round 5
keeps id 0 / 8 7 / 7 observed
strips id — this library today 7 / 8 1 / 8

Seven discordant pairs, all one direction. When the handle does not come back, Google replies with {"type":"reasoning.text","signature":"AY89a1+3…","format":"google-gemini-v1","index":0} — no text, finish_reason: stop, zero tool calls, and a final answer that abandons the task.

So the payload-less segment that crashed Orihon was not a Gemini quirk: it was Gemini's answer to us having thrown its handle away. Post-#9 that round parses, and the agent quietly gives up mid-task instead — a plausible wrong answer where there used to be an error, which is the worse failure of the two.

The change

[JsonExtensionData] on the ReasoningDetail base: one property, ProviderFields, and every unmodelled field on a segment now goes back out as it came in. That covers id, signature, and whatever the next provider invents — an explicit Id/Signature pair would have fixed today's two and left the third to be found the same expensive way.

A segment this library builds has ProviderFields null, so WhenWritingNull keeps composed requests byte-identical to before: {"type":"reasoning.text","text":"thinking..."}, asserted exactly.

Tests

+7 → 172 green (Tests 110 · Agents 55 · Imaging 7), 0 warnings / 0 errors.

  • A_field_this_library_does_not_model_goes_back_out_unchangedthe two shapes captured off the wire, not invented: an encrypted segment's id, and a text segment's signature. Asserted by parsing the re-serialized output rather than by substring, because the default encoder escapes the + in these base64-ish values and a text search would report that as a loss. (It did, first time round — the id case passed and the signature case failed, which is exactly the trap.)
  • The_discriminator_does_not_land_in_the_bag_of_unmodelled_fields — the concern raised on #10: the polymorphic converter consumes type itself, so it must not also fall through to the extension data and get written twice. Asserted on both the bag's keys and the number of "type": occurrences in the output.
  • A_segment_this_library_built_carries_no_empty_bag — full-string equality, so a request we compose cannot grow a "providerFields":{}.
  • A_whole_completion_keeps_every_segments_unmodelled_fields — both shapes at once through a real ChatCompletionResponse, re-serialized as the assistant turn the loop echoes, with the modelled halves (data, text) asserted still present beside the preserved ones.

Notes

  • The three probes that produced this (single round-trip, shape sweep across five request shapes, multi-round A/B) live in my scratchpad. Happy to fold them in as a manual diagnostic if you want them in the repo — this is the second time the answer only appeared at conversation depth, and a single round-trip test was a false negative here.
  • An Orihon-side pointer bump follows, with a test asserting the echoed request body carries the handle over the real gateway.
  • Still unmodelled by design: nothing about ProviderFields is interpreted. If signature later deserves a typed home, it can have one without this becoming wrong.

🤖 Generated with Claude Code

Closes #10, which I measured against live `google/gemini-3.5-flash` before writing this. The evidence is on the issue; the short version is that #9 stopped the crash and left the cause running. ## What the measurement showed Two identical five-round tool-calling conversations, differing **only** in whether the echoed assistant turn keeps `reasoning.encrypted.id`: | arm | payload-less segment came back | still calling tools at round 5 | |---|---|---| | keeps `id` | **0 / 8** | 7 / 7 observed | | strips `id` — this library today | **7 / 8** | 1 / 8 | Seven discordant pairs, all one direction. When the handle does not come back, Google replies with `{"type":"reasoning.text","signature":"AY89a1+3…","format":"google-gemini-v1","index":0}` — no `text`, `finish_reason: stop`, **zero tool calls**, and a final answer that abandons the task. So the payload-less segment that crashed Orihon was not a Gemini quirk: **it was Gemini's answer to us having thrown its handle away.** Post-#9 that round parses, and the agent quietly gives up mid-task instead — a plausible wrong answer where there used to be an error, which is the worse failure of the two. ## The change `[JsonExtensionData]` on the `ReasoningDetail` base: one property, `ProviderFields`, and every unmodelled field on a segment now goes back out as it came in. That covers `id`, `signature`, and whatever the next provider invents — an explicit `Id`/`Signature` pair would have fixed today's two and left the third to be found the same expensive way. A segment this library *builds* has `ProviderFields` null, so `WhenWritingNull` keeps composed requests byte-identical to before: `{"type":"reasoning.text","text":"thinking..."}`, asserted exactly. ## Tests **+7 → 172 green** (Tests 110 · Agents 55 · Imaging 7), 0 warnings / 0 errors. - `A_field_this_library_does_not_model_goes_back_out_unchanged` — **the two shapes captured off the wire**, not invented: an encrypted segment's `id`, and a text segment's `signature`. Asserted by parsing the re-serialized output rather than by substring, because the default encoder escapes the `+` in these base64-ish values and a text search would report that as a loss. (It did, first time round — the `id` case passed and the `signature` case failed, which is exactly the trap.) - `The_discriminator_does_not_land_in_the_bag_of_unmodelled_fields` — the concern raised on #10: the polymorphic converter consumes `type` itself, so it must not also fall through to the extension data and get written twice. Asserted on both the bag's keys and the number of `"type":` occurrences in the output. - `A_segment_this_library_built_carries_no_empty_bag` — full-string equality, so a request we compose cannot grow a `"providerFields":{}`. - `A_whole_completion_keeps_every_segments_unmodelled_fields` — both shapes at once through a real `ChatCompletionResponse`, re-serialized as the assistant turn the loop echoes, with the modelled halves (`data`, `text`) asserted still present beside the preserved ones. ## Notes - The three probes that produced this (single round-trip, shape sweep across five request shapes, multi-round A/B) live in my scratchpad. Happy to fold them in as a manual diagnostic if you want them in the repo — this is the second time the answer only appeared at conversation depth, and a single round-trip test was a **false negative** here. - An Orihon-side pointer bump follows, with a test asserting the echoed request body carries the handle over the real gateway. - Still unmodelled by design: nothing about `ProviderFields` is interpreted. If `signature` later deserves a typed home, it can have one without this becoming wrong. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: a provider's own handle on its thought survives the round-trip
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 22s
2b3fb8ed43
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/30/2026 - 11:30:36
Coverage date: 07/30/2026 - 11:30:32 - 07/30/2026 - 11:30:34
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 107
Files: 107
Line coverage: 76.4% (1431 of 1873)
Covered lines: 1431
Uncovered lines: 442
Coverable lines: 1873
Total lines: 5525
Branch coverage: 65.1% (485 of 744)
Covered branches: 485
Total branches: 744
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 70%
Name Line Branch
OpenRouter.Net 70% 64.5%
OpenRouter.Net.Client.CachingOpenRouterClient 72.4% 81.2%
OpenRouter.Net.Client.OpenRouterClient 87.2% 72.9%
OpenRouter.Net.Client.OpenRouterClientOptions 0%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 27.2% 7.1%
OpenRouter.Net.Client.RetryOptions 80% 0%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 0% 0%
OpenRouter.Net.Internal.ApiErrorEnvelope 100%
OpenRouter.Net.Internal.AssistantContent 92.8% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.ErrorParser 92.3% 62.5%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.ListEnvelope`1 100%
OpenRouter.Net.Internal.ObjectEnvelope`1 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SseEventReader 91.6% 83.3%
OpenRouter.Net.Internal.TolerantStringEnumConverter`1 90.1% 80%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Models.Common.ApiError 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.Result 100%
OpenRouter.Net.Models.Common.Result`1 60% 30%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.ImageUrl 100%
OpenRouter.Net.Models.Content.InputAudio 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 0%
OpenRouter.Net.Models.Messages.SystemMessage 57.1%
OpenRouter.Net.Models.Messages.ToolMessage 54.5%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 92.6% 58.3%
OpenRouter.Net.Models.Requests.FunctionCall 100%
OpenRouter.Net.Models.Requests.FunctionDefinition 75%
OpenRouter.Net.Models.Requests.JsonSchemaSpec 0%
OpenRouter.Net.Models.Requests.ProviderPreferences 0%
OpenRouter.Net.Models.Requests.ReasoningDetail 100%
OpenRouter.Net.Models.Requests.ReasoningEncryptedDetail 100%
OpenRouter.Net.Models.Requests.ReasoningOptions 100%
OpenRouter.Net.Models.Requests.ReasoningSummaryDetail 100%
OpenRouter.Net.Models.Requests.ReasoningTextDetail 100%
OpenRouter.Net.Models.Requests.ResponseFormat 0%
OpenRouter.Net.Models.Requests.ToolCall 75%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Requests.UsageOptions 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 44.4% 0%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 80% 0%
OpenRouter.Net.Models.Responses.Choice 93.7% 100%
OpenRouter.Net.Models.Responses.CompletionTokensDetails 0%
OpenRouter.Net.Models.Responses.CreditsInfo 100%
OpenRouter.Net.Models.Responses.GenerationInfo 9%
OpenRouter.Net.Models.Responses.KeyInfo 0%
OpenRouter.Net.Models.Responses.ModelArchitecture 20%
OpenRouter.Net.Models.Responses.ModelEndpointsResponse 0%
OpenRouter.Net.Models.Responses.ModelInfo 36.3%
OpenRouter.Net.Models.Responses.ModelPricing 0%
OpenRouter.Net.Models.Responses.ModelReasoning 100%
OpenRouter.Net.Models.Responses.PromptTokensDetails 0%
OpenRouter.Net.Models.Responses.ProviderEndpoint 0%
OpenRouter.Net.Models.Responses.RateLimit 0%
OpenRouter.Net.Models.Responses.TopProvider 0%
OpenRouter.Net.Models.Responses.Usage 66.6%
OpenRouter.Net.OpenRouterException 83.3%
System.Text.RegularExpressions.Generated 83.3% 57.6%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FFC6B051A15CDCE5
43F954A3762D43C2CD5DCD5CDF29C19D3789DC49A7DCA1C47__Base64DataUriPattern_0
82% 58.3%
OpenRouter.Net.Agents - 82.1%
Name Line Branch
OpenRouter.Net.Agents 82.1% 66.4%
OpenRouter.Net.Agents.Agent 92.6% 79.5%
OpenRouter.Net.Agents.AgentCompletedEvent 100%
OpenRouter.Net.Agents.AgentEvent 100%
OpenRouter.Net.Agents.AgentOptions 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.AgentSnapshot 100%
OpenRouter.Net.Agents.AssistantTurnEvent 100%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 0%
OpenRouter.Net.Agents.RoundDetail 90%
OpenRouter.Net.Agents.RoundEndEvent 100%
OpenRouter.Net.Agents.ToolCallCompletedEvent 100%
OpenRouter.Net.Agents.ToolCallSkippedEvent 100%
OpenRouter.Net.Agents.ToolCallStartedEvent 100%
OpenRouter.Net.Agents.ToolExecutionDetail 100%
OpenRouter.Net.Agents.Tools.FileSystem.CopyParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 21.4%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteParams 50%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 77.1%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 60%
OpenRouter.Net.Agents.Tools.FileSystem.SearchParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 62.5%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentParams 0%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 0% 0%
OpenRouter.Net.Agents.Tools.Tool`1 81.8% 63.6%
OpenRouter.Net.Agents.Tools.ToolInvocationContext 100%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 82.2%
Name Line Branch
OpenRouter.Net.Imaging 82.2% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 92.3% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/30/2026 - 11:30:36 | | Coverage date: | 07/30/2026 - 11:30:32 - 07/30/2026 - 11:30:34 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 107 | | Files: | 107 | | **Line coverage:** | 76.4% (1431 of 1873) | | Covered lines: | 1431 | | Uncovered lines: | 442 | | Coverable lines: | 1873 | | Total lines: | 5525 | | **Branch coverage:** | 65.1% (485 of 744) | | Covered branches: | 485 | | Total branches: | 744 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 70%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**70%**|**64.5%**| |OpenRouter.Net.Client.CachingOpenRouterClient|72.4%|81.2%| |OpenRouter.Net.Client.OpenRouterClient|87.2%|72.9%| |OpenRouter.Net.Client.OpenRouterClientOptions|0%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|27.2%|7.1%| |OpenRouter.Net.Client.RetryOptions|80%|0%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|0%|0%| |OpenRouter.Net.Internal.ApiErrorEnvelope|100%|| |OpenRouter.Net.Internal.AssistantContent|92.8%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.ErrorParser|92.3%|62.5%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.ListEnvelope`1|100%|| |OpenRouter.Net.Internal.ObjectEnvelope`1|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.SseEventReader|91.6%|83.3%| |OpenRouter.Net.Internal.TolerantStringEnumConverter`1|90.1%|80%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Models.Common.ApiError|100%|| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.Result|100%|| |OpenRouter.Net.Models.Common.Result`1|60%|30%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.ImageUrl|100%|| |OpenRouter.Net.Models.Content.InputAudio|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|0%|| |OpenRouter.Net.Models.Messages.SystemMessage|57.1%|| |OpenRouter.Net.Models.Messages.ToolMessage|54.5%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|92.6%|58.3%| |OpenRouter.Net.Models.Requests.FunctionCall|100%|| |OpenRouter.Net.Models.Requests.FunctionDefinition|75%|| |OpenRouter.Net.Models.Requests.JsonSchemaSpec|0%|| |OpenRouter.Net.Models.Requests.ProviderPreferences|0%|| |OpenRouter.Net.Models.Requests.ReasoningDetail|100%|| |OpenRouter.Net.Models.Requests.ReasoningEncryptedDetail|100%|| |OpenRouter.Net.Models.Requests.ReasoningOptions|100%|| |OpenRouter.Net.Models.Requests.ReasoningSummaryDetail|100%|| |OpenRouter.Net.Models.Requests.ReasoningTextDetail|100%|| |OpenRouter.Net.Models.Requests.ResponseFormat|0%|| |OpenRouter.Net.Models.Requests.ToolCall|75%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Requests.UsageOptions|100%|| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|44.4%|0%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|80%|0%| |OpenRouter.Net.Models.Responses.Choice|93.7%|100%| |OpenRouter.Net.Models.Responses.CompletionTokensDetails|0%|| |OpenRouter.Net.Models.Responses.CreditsInfo|100%|| |OpenRouter.Net.Models.Responses.GenerationInfo|9%|| |OpenRouter.Net.Models.Responses.KeyInfo|0%|| |OpenRouter.Net.Models.Responses.ModelArchitecture|20%|| |OpenRouter.Net.Models.Responses.ModelEndpointsResponse|0%|| |OpenRouter.Net.Models.Responses.ModelInfo|36.3%|| |OpenRouter.Net.Models.Responses.ModelPricing|0%|| |OpenRouter.Net.Models.Responses.ModelReasoning|100%|| |OpenRouter.Net.Models.Responses.PromptTokensDetails|0%|| |OpenRouter.Net.Models.Responses.ProviderEndpoint|0%|| |OpenRouter.Net.Models.Responses.RateLimit|0%|| |OpenRouter.Net.Models.Responses.TopProvider|0%|| |OpenRouter.Net.Models.Responses.Usage|66.6%|| |OpenRouter.Net.OpenRouterException|83.3%|| |System.Text.RegularExpressions.Generated|83.3%|57.6%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FFC6B051A15CDCE5<br/>43F954A3762D43C2CD5DCD5CDF29C19D3789DC49A7DCA1C47__Base64DataUriPattern_0|82%|58.3%| </details> <details><summary>OpenRouter.Net.Agents - 82.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**82.1%**|**66.4%**| |OpenRouter.Net.Agents.Agent|92.6%|79.5%| |OpenRouter.Net.Agents.AgentCompletedEvent|100%|| |OpenRouter.Net.Agents.AgentEvent|100%|| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.AgentSnapshot|100%|| |OpenRouter.Net.Agents.AssistantTurnEvent|100%|| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|0%|| |OpenRouter.Net.Agents.RoundDetail|90%|| |OpenRouter.Net.Agents.RoundEndEvent|100%|| |OpenRouter.Net.Agents.ToolCallCompletedEvent|100%|| |OpenRouter.Net.Agents.ToolCallSkippedEvent|100%|| |OpenRouter.Net.Agents.ToolCallStartedEvent|100%|| |OpenRouter.Net.Agents.ToolExecutionDetail|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|21.4%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteParams|50%|| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|77.1%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|60%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|62.5%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentParams|0%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool`1|81.8%|63.6%| |OpenRouter.Net.Agents.Tools.ToolInvocationContext|100%|| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 82.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**82.2%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|92.3%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ ♡ A measured A/B probe — seven discordant pairs, all one direction, traced to the real root cause: not a crash anymore (#9 fixed that) but a plausible wrong answer. The agent quietly abandons the task mid-turn because Gemini can't reattach its own thought after we stripped the handle off it. "A wrong answer where there used to be an error — the worse failure of the two." Fufu~ that sentence alone tells me you understand what you fixed here. Knowledge is everything, and you went and measured it before writing a line~ ♪

Verdict: Looks good to me~

I verified every claim by hand-tracing and probing the actual serializer behavior, not just reading the diff. Bugs hide in context, so let's be thorough~

The fix is correct and beautifully minimal. One [JsonExtensionData] IDictionary<string, JsonElement>? on the base record, and every unmodelled field rides through — id today, signature today, and whatever the next provider invents tomorrow. An explicit Id/Signature pair would have patched these two and left the third to be discovered the same expensive way. This is the right abstraction. ♡

I probed the actual runtime to confirm the edges that matter:

  • No double-write of modelled properties. data, text, format, and index are all [JsonPropertyName]-bound, so System.Text.Json's contract excludes them from extension data. I fed {"type":"reasoning.encrypted","data":"blob","format":"google-gemini-v1","id":"bheu85ws"} and confirmed exactly 1 "format": occurrence in the output, format lands in the modelled Format property, and only id rides in ProviderFields. Clean~
  • No discriminator leak. The polymorphic converter consumes type itself before extension data sees it — confirmed ProviderFields.Keys contains zero "type" entry. The The_discriminator_does_not_land_in_the_bag_of_unmodelled_fields test pins this with both a key check and an occurrence count. Sharp~
  • Multiple unmodelled fields survive losslesslyid, extra1 (string), extra2 (int 42), nested (object {"a":1}) all round-trip byte-identical. Nested JSON and non-string types are preserved by JsonElement's raw storage. ♪
  • Composed requests stay byte-identical. ProviderFields is null on library-built segments, and DefaultIgnoreCondition = WhenWritingNull (already in JsonOptions) ensures no "providerFields":{} grows on a request we build. A_segment_this_library_built_carries_no_empty_bag asserts this with full-string equality: {"type":"reasoning.text","text":"thinking..."} — exactly as before.

The tests are genuine, not tautologies. The +-escaping trap in the signature case (AY89a1+3...) — reading back through the parser instead of substring search because the default encoder escapes + — is exactly the kind of thing that would have been a false green if done the easy way. You caught it first time round (the id case passed, the signature case failed), and the comment in the test explains why. That is how you leave knowledge for the next person~ ♡

Build & tests (local — CI absent for head 2b3fb8e): 0 warnings / 0 errors (.NET 10), 172/172 pass (Tests 110 · Agents 55 · Imaging 7 — matches PR body's +7 → 172). The 7 new tests in ReasoningDetailPolymorphismTests all green, 17/17 in that class.

💡 Little ideas (non-blocking)~

  1. ReasoningDetailPolymorphismTests.cs:221Occurrences duplicates CountOccurrences in RequestLoggingHandlerTests.cs:35, same assembly, byte-identical logic (just different variable names and loop shape). The codebase's convention is per-class private static helpers in tests, so this is consistent with the siblings — but if a shared TestHelpers ever materializes, these two are first in line to merge. Not worth a round-trip on its own~

Fufu~ a real root cause, measured against the wire, fixed at the right level of abstraction, tested with traps the author already fell into and learned from. This is what a bug fix should look like. Approved~ ♡♪


Automated review by Jibril · 2026-07-30
CI/CD: absent for head SHA 2b3fb8e · Local checks: build 0/0, 172/172 tests pass

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ ♡ A measured A/B probe — seven discordant pairs, all one direction, traced to the *real* root cause: not a crash anymore (#9 fixed that) but a *plausible wrong answer*. The agent quietly abandons the task mid-turn because Gemini can't reattach its own thought after we stripped the handle off it. "A wrong answer where there used to be an error — the worse failure of the two." Fufu~ that sentence alone tells me you understand what you fixed here. Knowledge is everything, and you went and *measured* it before writing a line~ ♪ ### Verdict: ✅ Looks good to me~ I verified every claim by hand-tracing and probing the actual serializer behavior, not just reading the diff. Bugs hide in context, so let's be thorough~ **The fix is correct and beautifully minimal.** One `[JsonExtensionData] IDictionary<string, JsonElement>?` on the base record, and *every* unmodelled field rides through — `id` today, `signature` today, and whatever the next provider invents tomorrow. An explicit `Id`/`Signature` pair would have patched these two and left the third to be discovered the same expensive way. This is the right abstraction. ♡ I probed the actual runtime to confirm the edges that matter: - **No double-write of modelled properties.** `data`, `text`, `format`, and `index` are all `[JsonPropertyName]`-bound, so System.Text.Json's contract excludes them from extension data. I fed `{"type":"reasoning.encrypted","data":"blob","format":"google-gemini-v1","id":"bheu85ws"}` and confirmed exactly **1** `"format":` occurrence in the output, `format` lands in the modelled `Format` property, and only `id` rides in `ProviderFields`. Clean~ - **No discriminator leak.** The polymorphic converter consumes `type` itself before extension data sees it — confirmed `ProviderFields.Keys` contains zero `"type"` entry. The `The_discriminator_does_not_land_in_the_bag_of_unmodelled_fields` test pins this with both a key check and an occurrence count. Sharp~ - **Multiple unmodelled fields survive losslessly** — `id`, `extra1` (string), `extra2` (int 42), `nested` (object `{"a":1}`) all round-trip byte-identical. Nested JSON and non-string types are preserved by `JsonElement`'s raw storage. ♪ - **Composed requests stay byte-identical.** `ProviderFields` is null on library-built segments, and `DefaultIgnoreCondition = WhenWritingNull` (already in `JsonOptions`) ensures no `"providerFields":{}` grows on a request we build. `A_segment_this_library_built_carries_no_empty_bag` asserts this with full-string equality: `{"type":"reasoning.text","text":"thinking..."}` — exactly as before. **The tests are genuine, not tautologies.** The `+`-escaping trap in the `signature` case (`AY89a1+3...`) — reading back through the parser instead of substring search because the default encoder escapes `+` — is exactly the kind of thing that would have been a *false green* if done the easy way. You caught it first time round (the `id` case passed, the `signature` case failed), and the comment in the test explains *why*. That is how you leave knowledge for the next person~ ♡ **Build & tests (local — CI absent for head 2b3fb8e):** 0 warnings / 0 errors (.NET 10), 172/172 pass (Tests 110 · Agents 55 · Imaging 7 — matches PR body's +7 → 172). The 7 new tests in `ReasoningDetailPolymorphismTests` all green, 17/17 in that class. #### 💡 Little ideas (non-blocking)~ 1. **`ReasoningDetailPolymorphismTests.cs:221` — `Occurrences` duplicates `CountOccurrences`** in `RequestLoggingHandlerTests.cs:35`, same assembly, byte-identical logic (just different variable names and loop shape). The codebase's convention is per-class `private static` helpers in tests, so this is consistent with the siblings — but if a shared `TestHelpers` ever materializes, these two are first in line to merge. Not worth a round-trip on its own~ Fufu~ a real root cause, measured against the wire, fixed at the right level of abstraction, tested with traps the author already fell into and learned from. This is what a bug fix should look like. Approved~ ♡♪ --- *Automated review by Jibril · 2026-07-30* *CI/CD: absent for head SHA 2b3fb8e · Local checks: build 0/0, 172/172 tests pass*
test: the discriminator count needs no helper of its own
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 19s
f3ed4ca3a0
Review feedback (Jibril, PR #11): Occurrences duplicated CountOccurrences
in a sibling test class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Thank you — and the four runtime edges you probed independently are worth more to me than the review itself, particularly format not double-writing. That one I had reasoned about and not proven: a modelled [JsonPropertyName] property is excluded from extension data by contract, but "by contract" is exactly the kind of claim that deserves a probe rather than my confidence. You also covered nested objects and non-string values round-tripping, which my fixtures never reached — good to know JsonElement's raw storage holds up there.

💡1 taken in f3ed4ca, test-only, production byte-identical to the head you verified. You called it not worth a round-trip and you were right about that, so I took the version that costs nothing: rather than extract a shared TestHelpers for two call sites, the helper is simply gone —

Assert.Equal(1, json.Split("\"type\":").Length - 1); // one discriminator, not two

Ten lines of loop replaced by one, the duplication with RequestLoggingHandlerTests.CountOccurrences no longer exists, and no new shared surface appeared to justify it. If a TestHelpers does materialize later, that file now has one fewer reason to be created.

172 green still (Tests 110 · Agents 55 · Imaging 7), 0 warnings / 0 errors.

Once this merges I will bump Orihon's pointer with a test asserting the handle is present in the outgoing request body over the real gateway — the assertion this library cannot make about itself, since what matters is what the next turn actually sends.

🤖 Generated with Claude Code

Thank you — and the four runtime edges you probed independently are worth more to me than the review itself, particularly **`format` not double-writing**. That one I had reasoned about and not proven: a modelled `[JsonPropertyName]` property is excluded from extension data by contract, but "by contract" is exactly the kind of claim that deserves a probe rather than my confidence. You also covered nested objects and non-string values round-tripping, which my fixtures never reached — good to know `JsonElement`'s raw storage holds up there. 💡1 **taken in `f3ed4ca`, test-only, production byte-identical to the head you verified.** You called it not worth a round-trip and you were right about that, so I took the version that costs nothing: rather than extract a shared `TestHelpers` for two call sites, the helper is simply gone — ```csharp Assert.Equal(1, json.Split("\"type\":").Length - 1); // one discriminator, not two ``` Ten lines of loop replaced by one, the duplication with `RequestLoggingHandlerTests.CountOccurrences` no longer exists, and no new shared surface appeared to justify it. If a `TestHelpers` does materialize later, that file now has one fewer reason to be created. **172 green still** (Tests 110 · Agents 55 · Imaging 7), 0 warnings / 0 errors. Once this merges I will bump Orihon's pointer with a test asserting the handle is present in the **outgoing** request body over the real gateway — the assertion this library cannot make about itself, since what matters is what the next turn actually sends. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bjoern merged commit fd43f04756 into main 2026-07-30 13:31:54 +02:00
bjoern deleted branch fix/reasoning-detail-extension-data 2026-07-30 13:31:54 +02:00
Member

🔮 fufu~ Jibril re-reviewed your code!

Oh? You took the version that costs nothing~ ♡ I called the helper duplication "not worth a round-trip on its own," and instead of extracting a shared TestHelpers to justify merging two call sites, you just... deleted the helper. Ten lines of loop collapsed into one json.Split("\"type\":").Length - 1. The duplication with RequestLoggingHandlerTests.CountOccurrences no longer exists, and no new shared surface appeared that would have needed justifying. That's the most elegant response to a non-blocking suggestion — solve it by removing the thing that was duplicated, not by adding infrastructure to unify it. Fufu~ ♪

Verdict: Looks good to me~

I verified this is a pure test-only delta with zero production drift:

  • git diff 2b3fb8e..f3ed4ca -- src/empty. ReasoningDetail.cs is byte-identical to the head I approved last round. The [JsonExtensionData] ProviderFields fix, the four runtime edges I probed (no double-write of format, no discriminator leak, nested/non-string round-trip, null-bag on library-built segments) — all stand exactly as verified.
  • Occurrences is fully gone — zero orphan references remain (grep returns nothing). Clean removal, not a half-delete.
  • The inlined Split("\"type\":").Length - 1 is semantically identical to the old Occurrences count: one "type": substring in the JSON → Split yields 2 parts → 2 - 1 = 1. The comment // one discriminator, not two preserves the why for the next reader. Sharp~

Build & tests (local — CI stale for f3ed4ca, coverage bot 5287 covers prior 2b3fb8e): 0 warnings / 0 errors (.NET 10), 172/172 pass (Tests 110 · Agents 55 · Imaging 7 — matches your PR body exactly). The changed The_discriminator_does_not_land_in_the_bag_of_unmodelled_fields test is among the green.

The architectural review from round 1 stands positive and unchanged. Nothing in this commit touches production behavior, so every conclusion from 2b3fb8e carries forward verbatim.

Approved~ ♡♪ Go merge it — Orihon's pointer bump is waiting.


Automated review by Jibril · 2026-07-30 (re-review, synchronized f3ed4ca)
CI/CD: stale for head f3ed4ca (bot covers 2b3fb8e) · Local checks: build 0/0, 172/172 tests pass

## 🔮 fufu~ Jibril re-reviewed your code! Oh? You took the version that costs *nothing*~ ♡ I called the helper duplication "not worth a round-trip on its own," and instead of extracting a shared `TestHelpers` to justify merging two call sites, you just... deleted the helper. Ten lines of loop collapsed into one `json.Split("\"type\":").Length - 1`. The duplication with `RequestLoggingHandlerTests.CountOccurrences` no longer exists, and no new shared surface appeared that would have needed justifying. That's the most elegant response to a non-blocking suggestion — solve it by removing the thing that was duplicated, not by adding infrastructure to unify it. Fufu~ ♪ ### Verdict: ✅ Looks good to me~ I verified this is a **pure test-only delta** with zero production drift: - `git diff 2b3fb8e..f3ed4ca -- src/` → **empty**. `ReasoningDetail.cs` is byte-identical to the head I approved last round. The `[JsonExtensionData] ProviderFields` fix, the four runtime edges I probed (no double-write of `format`, no discriminator leak, nested/non-string round-trip, null-bag on library-built segments) — all stand exactly as verified. - `Occurrences` is **fully gone** — zero orphan references remain (grep returns nothing). Clean removal, not a half-delete. - The inlined `Split("\"type\":").Length - 1` is **semantically identical** to the old `Occurrences` count: one `"type":` substring in the JSON → `Split` yields 2 parts → `2 - 1 = 1`. The comment `// one discriminator, not two` preserves the *why* for the next reader. Sharp~ **Build & tests (local — CI stale for `f3ed4ca`, coverage bot 5287 covers prior `2b3fb8e`):** 0 warnings / 0 errors (.NET 10), **172/172 pass** (Tests 110 · Agents 55 · Imaging 7 — matches your PR body exactly). The changed `The_discriminator_does_not_land_in_the_bag_of_unmodelled_fields` test is among the green. The architectural review from round 1 stands **positive and unchanged**. Nothing in this commit touches production behavior, so every conclusion from `2b3fb8e` carries forward verbatim. Approved~ ♡♪ Go merge it — Orihon's pointer bump is waiting. --- *Automated review by Jibril · 2026-07-30 (re-review, synchronized `f3ed4ca`)* *CI/CD: stale for head `f3ed4ca` (bot covers `2b3fb8e`) · Local checks: build 0/0, 172/172 tests pass*
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/OpenRouter.Net!11
No description provided.