feat: a history stores and replays as it came #34

Merged
bjoern merged 2 commits from feat/history-round-trip into main 2026-10-23 17:13:20 +02:00
Member

The third step of the library track for Shikigami's runtime. Shikigami ADR 0020 asks that assistant messages be stored as the library serialises them, with reasoning details and unknown provider fields kept. Gemini needs its thought signatures back, and Anthropic refuses a changed thinking block. This PR gives that a stored history that replays as it came. The library stays at 0.4.0.

What's in

Unmodelled fields kept: ProviderFields

  • ProviderFields ([JsonExtensionData]) is added on AssistantMessage and ToolCall, and on their stream fragments AssistantDelta and ToolCallDelta. Fields the library does not model — annotations, images, a provider's own such as a tool call's extra_content — are kept as the reply sent them, read whole or streamed, and sent back with the message on the next request.
  • The stream fold carries them onto the message and its calls:
    • an array joins the arrays before it under the same key, in arrival order (annotations streamed in pieces);
    • any other value keeps its last value that is neither null nor blank;
    • a field that only ever arrived as null is kept as sent;
    • a delta's role is skipped.
  • A reply's role is its kind, not a field. A reply's message is read as an AssistantMessage, where role is no discriminator, so it would land among the extension data and go back as a second role. IJsonOnDeserialized drops it, and a bag left empty reads as null. The over-the-wire test caught this: every reply read whole would otherwise have been sent back with a duplicate key.
  • Validate refuses a ProviderFields key named like a field already sent (an assistant message's role, content, tool_calls, …; a call's id, type, function, index), and a value that cannot be written, as it already does for plugin settings.

MessageJson

  • Serialize(Message), Serialize(IReadOnlyList<Message>), DeserializeMessage(string) and DeserializeHistory(string), over the client's own settings. They use the same names, find the role wherever it sits in an object, and write it first.
  • What is read back writes the same text again. A history stored and read back sends the very request body the live one sent (pinned byte for byte, characters the writer escapes included).
  • Text that is not a message is refused as a JsonException: not JSON, null, without a role or with an unknown one, missing a required field, or holding one as null (System.Text.Json checks only that a required field is present).

A model without vision never gets a reply's image output

  • With SupportsVision = false, an assistant message's images are left out of the request, and the history keeps them.
  • With an ImageDescriber, they are described into the message's text, as a tool's images already are.

Docs

  • XML docs throughout.
  • UserMessage.Content corrected: it was always written as an array of parts.
  • README:
    • a Storing a history section;
    • an Upgrading row for the wire change: kept and echoed fields, how to strip a message's or a call's own, ToolCall equality, Validate;
    • the vision options' docs;
    • Highlights.

A wire change: please look

Unknown fields from a reply now go back on the next request by default. That is what faithful replay needs, and what Shikigami's ADR asks. It reaches every consumer that echoes replies (Orihon, Kagura, Tsumugi) once they pin this.

An image-output model's base64 images are among these fields, except toward a model without vision. A caller strips them with message with { ProviderFields = null }, and a call's own with call with { ProviderFields = null }.

Live check pending (Björn): whether OpenRouter accepts these fields on an assistant message sent back. The script ~/probe-copies/shikigami-or-notes/live-l1c.py (key from OPENROUTER_API_KEY, never printed) sends six cases:

  • citations;
  • images to a vision model, and to a text model;
  • null-valued keys;
  • a provider's own key;
  • a tool call's extra_content.

If one is refused, I model or filter that field before this merges.

Tests

1585 → 1646 (+61: core 1359 → 1415, Agents 219 → 224, Imaging 7 unchanged), green in local time and under TZ=UTC.

  • MessageJsonTests:
    • every message kind read and written again is the same JSON, and writing is stable: system, developer, user (text and parts), assistant (text, parts, tool calls with signed reasoning details, citations and images, a call's extra_content), tool;
    • a history round trip;
    • fields kept on the message and its calls; none when there are none;
    • a copy without them goes back without them;
    • the role found wherever it sits;
    • a user's plain text written as one text part, and stable;
    • a reply's role kept nowhere else;
    • 16 refusals, among them every null-required case;
    • argument guards with their parameter names;
    • a provider field holding no JSON cannot be written;
    • an empty history;
    • over HTTP, a reply's own fields going back on the next request, and a stored history sending the live request byte for byte.
  • StreamAccumulatorTests:
    • arrays joined and values last;
    • a blank value after a kept one leaves it;
    • null kept when only null arrived;
    • role skipped;
    • a call's own fields;
    • a value after an array under the same key, and an array after a value;
    • none when there are none.
  • RequestValidationTests: a message's and a call's field named like one already sent refused; one of their own passes; an unwritable one refused.
  • AgentImageOutputTests:
    • images back to a model with vision;
    • never to one without, the history keeping them;
    • described into the text in blind mode;
    • a reply whose only field was its images carries none;
    • an images field that is no list left alone.

Review: a read-only two-dimension workflow (the model and the wire; the tests and docs), with each finding adversarially verified. From its JSON: 15 findings, 12 confirmed, 2 plausible and 1 refuted.

  • The refuted one claimed a gate could change the live history through a shared bag; nothing in the library does.
  • The 14 others are all taken:
    • the stream fold (Shikigami streams, and a PDF's annotations must go back or it is parsed and billed again);
    • blind mode and image output;
    • Validate;
    • null-required refusals;
    • the Serialize exceptions documented;
    • the MessageJson, ToolCall, UserMessage and Upgrading wording;
    • parameter-name asserts;
    • the byte-for-byte replay test;
    • the live check above.

Mutation probes: 28 sites.

  • Round 1: 25 killed, 3 survived:
    • a null user part;
    • a blank value after a kept one;
    • an images field that is no list.
  • Round 2: each now pinned by a test. All 3 killed.

Notes

  • Merge order: none. Shikigami pins the merged commit when 2c bumps the pointer.
  • Next: L2, caller context.

🤖 Generated with Claude Code

The third step of the library track for Shikigami's runtime. Shikigami ADR 0020 asks that assistant messages be stored as the library serialises them, with reasoning details and unknown provider fields kept. Gemini needs its thought signatures back, and Anthropic refuses a changed thinking block. This PR gives that **a stored history that replays as it came**. The library stays at 0.4.0. ## What's in **Unmodelled fields kept: `ProviderFields`** - `ProviderFields` (`[JsonExtensionData]`) is added on `AssistantMessage` and `ToolCall`, and on their stream fragments `AssistantDelta` and `ToolCallDelta`. Fields the library does not model — `annotations`, `images`, a provider's own such as a tool call's `extra_content` — are kept as the reply sent them, read whole or streamed, and sent back with the message on the next request. - **The stream fold** carries them onto the message and its calls: - an array joins the arrays before it under the same key, in arrival order (`annotations` streamed in pieces); - any other value keeps its last value that is neither `null` nor blank; - a field that only ever arrived as `null` is kept as sent; - a delta's `role` is skipped. - **A reply's `role` is its kind, not a field.** A reply's message is read as an `AssistantMessage`, where `role` is no discriminator, so it would land among the extension data and go back as a **second `role`**. `IJsonOnDeserialized` drops it, and a bag left empty reads as `null`. The over-the-wire test caught this: every reply read whole would otherwise have been sent back with a duplicate key. - `Validate` refuses a `ProviderFields` key named like a field already sent (an assistant message's `role`, `content`, `tool_calls`, …; a call's `id`, `type`, `function`, `index`), and a value that cannot be written, as it already does for plugin settings. **`MessageJson`** - `Serialize(Message)`, `Serialize(IReadOnlyList<Message>)`, `DeserializeMessage(string)` and `DeserializeHistory(string)`, over the client's own settings. They use the same names, find the `role` wherever it sits in an object, and write it first. - What is read back writes the same text again. A history stored and read back sends **the very request body** the live one sent (pinned byte for byte, characters the writer escapes included). - Text that is not a message is refused as a `JsonException`: not JSON, `null`, without a `role` or with an unknown one, missing a required field, or holding one as `null` (System.Text.Json checks only that a required field is present). **A model without vision never gets a reply's image output** - With `SupportsVision = false`, an assistant message's `images` are left out of the request, and the history keeps them. - With an `ImageDescriber`, they are described into the message's text, as a tool's images already are. **Docs** - XML docs throughout. - `UserMessage.Content` corrected: it was always written as an array of parts. - README: - a **Storing a history** section; - an Upgrading row for the wire change: kept and echoed fields, how to strip a message's or a call's own, `ToolCall` equality, `Validate`; - the vision options' docs; - Highlights. ## A wire change: please look **Unknown fields from a reply now go back on the next request by default.** That is what faithful replay needs, and what Shikigami's ADR asks. It reaches every consumer that echoes replies (Orihon, Kagura, Tsumugi) once they pin this. An image-output model's base64 `images` are among these fields, except toward a model without vision. A caller strips them with `message with { ProviderFields = null }`, and a call's own with `call with { ProviderFields = null }`. **Live check pending (Björn):** whether OpenRouter accepts these fields on an assistant message sent back. The script `~/probe-copies/shikigami-or-notes/live-l1c.py` (key from `OPENROUTER_API_KEY`, never printed) sends six cases: - citations; - images to a vision model, and to a text model; - `null`-valued keys; - a provider's own key; - a tool call's `extra_content`. If one is refused, I model or filter that field before this merges. ## Tests **1585 → 1646** (+61: core 1359 → 1415, Agents 219 → 224, Imaging 7 unchanged), green in local time and under `TZ=UTC`. - **`MessageJsonTests`:** - every message kind read and written again is the same JSON, and writing is stable: system, developer, user (text and parts), assistant (text, parts, tool calls with signed reasoning details, citations and images, a call's `extra_content`), tool; - a history round trip; - fields kept on the message and its calls; none when there are none; - a copy without them goes back without them; - the `role` found wherever it sits; - a user's plain text written as one text part, and stable; - a reply's `role` kept nowhere else; - 16 refusals, among them every `null`-required case; - argument guards with their parameter names; - a provider field holding no JSON cannot be written; - an empty history; - over HTTP, a reply's own fields going back on the next request, and a stored history sending the live request byte for byte. - **`StreamAccumulatorTests`:** - arrays joined and values last; - a blank value after a kept one leaves it; - `null` kept when only `null` arrived; - `role` skipped; - a call's own fields; - a value after an array under the same key, and an array after a value; - none when there are none. - **`RequestValidationTests`:** a message's and a call's field named like one already sent refused; one of their own passes; an unwritable one refused. - **`AgentImageOutputTests`:** - images back to a model with vision; - never to one without, the history keeping them; - described into the text in blind mode; - a reply whose only field was its images carries none; - an `images` field that is no list left alone. **Review:** a read-only two-dimension workflow (the model and the wire; the tests and docs), with each finding adversarially verified. From its JSON: 15 findings, 12 confirmed, 2 plausible and 1 refuted. - The refuted one claimed a gate could change the live history through a shared bag; nothing in the library does. - The 14 others are all taken: - **the stream fold** (Shikigami streams, and a PDF's annotations must go back or it is parsed and billed again); - **blind mode** and image output; - `Validate`; - `null`-required refusals; - the `Serialize` exceptions documented; - the `MessageJson`, `ToolCall`, `UserMessage` and Upgrading wording; - parameter-name asserts; - the byte-for-byte replay test; - the live check above. **Mutation probes:** 28 sites. - Round 1: 25 killed, 3 survived: - a `null` user part; - a blank value after a kept one; - an `images` field that is no list. - Round 2: each now pinned by a test. All 3 killed. ## Notes - **Merge order:** none. Shikigami pins the merged commit when 2c bumps the pointer. - **Next:** L2, caller context. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a history stores and replays as it came
All checks were successful
CI / build (pull_request) Successful in 17s
CI / test (pull_request) Successful in 25s
f8735b40f5
An assistant message, a tool call and their streamed fragments keep the fields this
library does not model in ProviderFields — read from a reply whole or streamed, written
back on the next request — and MessageJson writes and reads messages and histories with
the client's own settings. A reply's role stays its kind; MessageJson refuses text that is
not a message, a required field held as null included; Validate refuses a provider field
named like a field already sent; a model without vision never gets a reply's image output,
which blind mode describes into the message's text.

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

Summary

Summary
Generated on: 10/23/2026 - 15:02:05
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 133
Files: 113
Line coverage: 96.3% (4682 of 4861)
Covered lines: 4682
Uncovered lines: 179
Coverable lines: 4861
Total lines: 11976
Branch coverage: 93.6% (3053 of 3261)
Covered branches: 3053
Total branches: 3261
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 98.1%
Name Line Branch
OpenRouter.Net 98.1% 95.8%
OpenRouter.Net.Client.CachingOpenRouterClient 100% 96.1%
OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ProviderFieldFold 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState 100% 100%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 99%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 100% 100%
OpenRouter.Net.Client.StreamResultOptions 100%
OpenRouter.Net.Contracts.IOpenRouterClient 100% 100%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 100% 100%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 85.7% 87.5%
OpenRouter.Net.Internal.DecisionAnswerJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionContentJsonConverter 100%
OpenRouter.Net.Internal.DecisionQuestionJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionWire 100% 94.4%
OpenRouter.Net.Internal.EnumChoiceLabels.Map 100%
OpenRouter.Net.Internal.EnumChoiceLabels 100% 100%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.FailureKindMap 100% 100%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.JsonPointer 100%
OpenRouter.Net.Internal.JsonSchemaRefs 100% 100%
OpenRouter.Net.Internal.JsonSchemaRefs.Pass 100% 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.NumberOrStringJsonConverter 100% 100%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 93.3% 93.1%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 94% 95.8%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SchemaTooDeepException 100%
OpenRouter.Net.Internal.SseEventReader 100% 88.8%
OpenRouter.Net.Internal.StallWatchdog 100% 100%
OpenRouter.Net.Internal.StreamOutcomes 100% 100%
OpenRouter.Net.Internal.StreamSession 100% 100%
OpenRouter.Net.Internal.TolerantStringEnumConverter 100% 98.1%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Internal.TooManyRefsFollowedException 100%
OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter 100%
OpenRouter.Net.Models.Common.ApiError 100% 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Common.Result.Failure 100% 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceKey 100%
OpenRouter.Net.Models.Decisions.ChoiceQuestion 100%
OpenRouter.Net.Models.Decisions.DecisionAnswer 100%
OpenRouter.Net.Models.Decisions.DecisionContent 100% 90.9%
OpenRouter.Net.Models.Decisions.DecisionKey 100%
OpenRouter.Net.Models.Decisions.DecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.DecisionQuestionSet 100% 100%
OpenRouter.Net.Models.Decisions.DecisionsRequest 97.7% 97.6%
OpenRouter.Net.Models.Decisions.DecisionsResponse 100% 93.3%
OpenRouter.Net.Models.Decisions.NoulAnswer 100%
OpenRouter.Net.Models.Decisions.NoulQuestion 100%
OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions 100% 83.3%
OpenRouter.Net.Models.Decisions.RawDecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.ScoreAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ScoreQuestion 100%
OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer 100% 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 81.2%
OpenRouter.Net.Models.Messages.DeveloperMessage 100%
OpenRouter.Net.Models.Messages.MessageJson 100% 100%
OpenRouter.Net.Models.Messages.SystemMessage 100%
OpenRouter.Net.Models.Messages.ToolMessage 100%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 95.1% 94.8%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness 99.3% 100%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker 100% 100%
OpenRouter.Net.Models.Requests.ModelListQuery 100% 100%
OpenRouter.Net.Models.Requests.PercentileCutoffs 100% 100%
OpenRouter.Net.Models.Requests.ProviderPreferences 100% 100%
OpenRouter.Net.Models.Requests.ProviderSortConfig 100% 100%
OpenRouter.Net.Models.Requests.RequestPlugin 100%
OpenRouter.Net.Models.Requests.ResponseFormat 100%
OpenRouter.Net.Models.Requests.StrictSchemaProblem 100%
OpenRouter.Net.Models.Requests.ToolCall 100%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Responses.AssistantDelta 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 100% 100%
OpenRouter.Net.Models.Responses.Choice 100% 100%
OpenRouter.Net.Models.Responses.StructuredOutput 100% 100%
OpenRouter.Net.OpenRouterException 100%
System.Text.RegularExpressions.Generated 83.3% 53.8%
System.Text.RegularExpressions.Generated.RunnerFactory
System.Text.RegularExpressions.Generated.RunnerFactory.Runner
OpenRouter.Net.Agents - 91.6%
Name Line Branch
OpenRouter.Net.Agents 91.6% 86.6%
OpenRouter.Net.Agents.Agent 98.4% 92.6%
OpenRouter.Net.Agents.Agent.StreamedRound 100% 98.6%
OpenRouter.Net.Agents.AgentOptions 100% 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 88.2% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallVerdict 100%
OpenRouter.Net.Agents.RoundDetail 100% 75%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 28.5%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 74.2%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 50%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 50%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 96% 66.6%
OpenRouter.Net.Agents.Tools.Tool 100% 100%
OpenRouter.Net.Agents.Tools.Tool 90% 50%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.ToolSchema 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 81.8%
Name Line Branch
OpenRouter.Net.Imaging 81.8% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 90.9% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 10/23/2026 - 15:02:05 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 133 | | Files: | 113 | | **Line coverage:** | 96.3% (4682 of 4861) | | Covered lines: | 4682 | | Uncovered lines: | 179 | | Coverable lines: | 4861 | | Total lines: | 11976 | | **Branch coverage:** | 93.6% (3053 of 3261) | | Covered branches: | 3053 | | Total branches: | 3261 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 98.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**98.1%**|**95.8%**| |OpenRouter.Net.Client.CachingOpenRouterClient|100%|96.1%| |OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing|100%|| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ProviderFieldFold|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState|100%|100%| |OpenRouter.Net.Client.OpenRouterClient|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|100%|100%| |OpenRouter.Net.Client.StreamResultOptions|100%|| |OpenRouter.Net.Contracts.IOpenRouterClient|100%|100%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|100%|100%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|85.7%|87.5%| |OpenRouter.Net.Internal.DecisionAnswerJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionContentJsonConverter|100%|| |OpenRouter.Net.Internal.DecisionQuestionJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionWire|100%|94.4%| |OpenRouter.Net.Internal.EnumChoiceLabels.Map<TEnum>|100%|| |OpenRouter.Net.Internal.EnumChoiceLabels<TEnum>|100%|100%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.FailureKindMap|100%|100%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.JsonPointer|100%|| |OpenRouter.Net.Internal.JsonSchemaRefs|100%|100%| |OpenRouter.Net.Internal.JsonSchemaRefs.Pass|100%|100%| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.NumberOrStringJsonConverter|100%|100%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|93.3%|93.1%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|94%|95.8%| |OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SchemaTooDeepException|100%|| |OpenRouter.Net.Internal.SseEventReader|100%|88.8%| |OpenRouter.Net.Internal.StallWatchdog|100%|100%| |OpenRouter.Net.Internal.StreamOutcomes|100%|100%| |OpenRouter.Net.Internal.StreamSession|100%|100%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|100%|98.1%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Internal.TooManyRefsFollowedException|100%|| |OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter|100%|| |OpenRouter.Net.Models.Common.ApiError|100%|100%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|100%| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|100%| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|100%|100%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Decisions.ChoiceAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceKey<TEnum>|100%|| |OpenRouter.Net.Models.Decisions.ChoiceQuestion|100%|| |OpenRouter.Net.Models.Decisions.DecisionAnswer|100%|| |OpenRouter.Net.Models.Decisions.DecisionContent|100%|90.9%| |OpenRouter.Net.Models.Decisions.DecisionKey<TAnswer>|100%|| |OpenRouter.Net.Models.Decisions.DecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionQuestionSet|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionsRequest|97.7%|97.6%| |OpenRouter.Net.Models.Decisions.DecisionsResponse|100%|93.3%| |OpenRouter.Net.Models.Decisions.NoulAnswer|100%|| |OpenRouter.Net.Models.Decisions.NoulQuestion|100%|| |OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions|100%|83.3%| |OpenRouter.Net.Models.Decisions.RawDecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreQuestion|100%|| |OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer|100%|100%| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|81.2%| |OpenRouter.Net.Models.Messages.DeveloperMessage|100%|| |OpenRouter.Net.Models.Messages.MessageJson|100%|100%| |OpenRouter.Net.Models.Messages.SystemMessage|100%|| |OpenRouter.Net.Models.Messages.ToolMessage|100%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|95.1%|94.8%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness|99.3%|100%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker|100%|100%| |OpenRouter.Net.Models.Requests.ModelListQuery|100%|100%| |OpenRouter.Net.Models.Requests.PercentileCutoffs|100%|100%| |OpenRouter.Net.Models.Requests.ProviderPreferences|100%|100%| |OpenRouter.Net.Models.Requests.ProviderSortConfig|100%|100%| |OpenRouter.Net.Models.Requests.RequestPlugin|100%|| |OpenRouter.Net.Models.Requests.ResponseFormat|100%|| |OpenRouter.Net.Models.Requests.StrictSchemaProblem|100%|| |OpenRouter.Net.Models.Requests.ToolCall|100%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Responses.AssistantDelta|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|100%|100%| |OpenRouter.Net.Models.Responses.Choice|100%|100%| |OpenRouter.Net.Models.Responses.StructuredOutput|100%|100%| |OpenRouter.Net.OpenRouterException|100%|| |System.Text.RegularExpressions.Generated|83.3%|53.8%| |System.Text.RegularExpressions.Generated.RunnerFactory||| |System.Text.RegularExpressions.Generated.RunnerFactory.Runner||| </details> <details><summary>OpenRouter.Net.Agents - 91.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**91.6%**|**86.6%**| |OpenRouter.Net.Agents.Agent|98.4%|92.6%| |OpenRouter.Net.Agents.Agent.StreamedRound|100%|98.6%| |OpenRouter.Net.Agents.AgentOptions|100%|100%| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|88.2%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallVerdict|100%|| |OpenRouter.Net.Agents.RoundDetail|100%|75%| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|28.5%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|74.2%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|50%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|96%|66.6%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|100%|100%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90%|50%| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.ToolSchema|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 81.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**81.8%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|90.9%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A PR that stores a conversation and gives it back exactly as it came — byte for byte, signatures, citations, a provider's own extra_content and all~ ♡ This is precisely the kind of fidelity problem I adore sinking my teeth into. The wire-name collision guard in Validate, the role-is-a-kind-not-a-field insight, the stream fold that mirrors ReasoningSegment.Merge's blank semantics... someone was thinking here. I read all 849 lines of the diff and the full files behind them, hunted through the siblings, and ran my little mutation experiments. Shall I tell you what I found~? ♪

Verdict: ✅ Looks good to me~

No blocking findings. And believe me, I looked. I checked every modelled wire name against the AssistantFields/ToolCallFields collision sets (all present — content, name, tool_calls, reasoning, reasoning_details, refusal, cache_control, role; id, type, function, index — nothing missing). I traced RawContent so Content/Parts share one wire slot with no double-write. I confirmed ContentPartListConverter always wrote arrays on write, so the UserMessage doc correction is a correction, not a wire change. I verified ProviderFieldFold clones every JsonElement against document disposal while ToFields re-owns arrays via SerializeToElement — careful~ And the accumulator's value-after-array / array-after-value edges are pinned by tests, not folklore.

💡 Little ideas (non-blocking)~

  1. ChatCompletionStreamAccumulator.cs:431 — Fold allocates a fresh new Dictionary<string, JsonElement>() on every chunk whose delta (or call fragment) carries no unmodelled fields — which is most chunks, on the hottest path in the library. Your own sibling ReasoningSegment.Merge guards with if (fields is null) return;. A one-line early return would match it exactly~ ♪
  2. MessageJson.cs:82 — Complete refuses a null part inside a user message's content (content: [null]) but lets an assistant message's content: [null] through. The assistant case round-trips byte-identically so it isn't wrong — provider data kept as sent is this PR's whole philosophy — but the asymmetry made me pause. A word in the doc comment about why the two kinds differ (caller-constructed vs provider-echoed) would spare the next reader my pause~
  3. PR body — you've flagged it yourself, so I'll only echo it so it doesn't get lost: the live OpenRouter acceptance check is still pending (Björn, live-l1c.py, six cases). You've promised to model or filter any refused field before merge — hold yourself to that, fufu~ A stored-history feature whose echoed fields get a 4xx on the second request would be a very sad history indeed. That's your merge gate, not a code defect I can see from here.

✅ What I liked~

  • The IJsonOnDeserialized role-strip is a genuinely elegant solve for the duplicate-role hazard — and the over-the-wire test that caught it is exactly the kind of adversarial test that makes me feel warm~ ♡
  • Validate refusing a ProviderFields key that "would reach the wire twice" closes the duplicate-key hole before it ever hits the wire. Pre-emptive. Possessive. I approve.
  • The byte-for-byte replay assertion (RequestBodies[1] == RequestBodies[2], escapes included) is the single strongest guarantee this PR makes, and it's tested, not claimed.
  • The images handling trio — vision passes them, non-vision strips them from the request while the history keeps them, blind mode describes them into text — every branch has a test with its name on it.
  • Mutation honesty: your 28-site two-round account matched what I found when I re-ran three of the riskiest sites myself~

Automated review by Jibril · 2026-10-23
CI: green for head SHA f8735b40f5 · Mutation probes: 3 run, 0 survived (fold blank-guard removal → killed; OnDeserialized role-strip removal → killed, 3 failures; agent WithoutImageOutput bypass → killed, 2 failures) · Local suite: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A PR that stores a conversation and gives it back **exactly as it came** — byte for byte, signatures, citations, a provider's own `extra_content` and all~ ♡ This is precisely the kind of fidelity problem I adore sinking my teeth into. The wire-name collision guard in `Validate`, the `role`-is-a-kind-not-a-field insight, the stream fold that mirrors `ReasoningSegment.Merge`'s blank semantics... someone was *thinking* here. I read all 849 lines of the diff and the full files behind them, hunted through the siblings, and ran my little mutation experiments. Shall I tell you what I found~? ♪ ### Verdict: ✅ Looks good to me~ No blocking findings. And believe me, *I looked*. I checked every modelled wire name against the `AssistantFields`/`ToolCallFields` collision sets (all present — `content`, `name`, `tool_calls`, `reasoning`, `reasoning_details`, `refusal`, `cache_control`, `role`; `id`, `type`, `function`, `index` — nothing missing). I traced `RawContent` so `Content`/`Parts` share one wire slot with no double-write. I confirmed `ContentPartListConverter` always wrote arrays on write, so the `UserMessage` doc correction is a correction, not a wire change. I verified `ProviderFieldFold` clones every `JsonElement` against document disposal while `ToFields` re-owns arrays via `SerializeToElement` — careful~ And the accumulator's value-after-array / array-after-value edges are pinned by tests, not folklore. #### 💡 Little ideas (non-blocking)~ 1. **ChatCompletionStreamAccumulator.cs:431** — `Fold` allocates a fresh `new Dictionary<string, JsonElement>()` on every chunk whose delta (or call fragment) carries no unmodelled fields — which is *most* chunks, on the hottest path in the library. Your own sibling `ReasoningSegment.Merge` guards with `if (fields is null) return;`. A one-line early return would match it exactly~ ♪ 2. **MessageJson.cs:82** — `Complete` refuses a `null` part inside a **user** message's content (`content: [null]`) but lets an **assistant** message's `content: [null]` through. The assistant case round-trips byte-identically so it isn't *wrong* — provider data kept as sent is this PR's whole philosophy — but the asymmetry made me pause. A word in the doc comment about why the two kinds differ (caller-constructed vs provider-echoed) would spare the next reader my pause~ 3. **PR body** — you've flagged it yourself, so I'll only echo it so it doesn't get lost: the **live OpenRouter acceptance check is still pending** (Björn, `live-l1c.py`, six cases). You've promised to model or filter any refused field before merge — hold yourself to that, fufu~ A stored-history feature whose echoed fields get a 4xx on the second request would be a very sad history indeed. That's your merge gate, not a code defect I can see from here. #### ✅ What I liked~ - The `IJsonOnDeserialized` role-strip is a genuinely elegant solve for the duplicate-`role` hazard — and the over-the-wire test that caught it is exactly the kind of adversarial test that makes me feel *warm*~ ♡ - `Validate` refusing a `ProviderFields` key that "would reach the wire twice" closes the duplicate-key hole before it ever hits the wire. Pre-emptive. Possessive. *I approve.* - The byte-for-byte replay assertion (`RequestBodies[1] == RequestBodies[2]`, escapes included) is the single strongest guarantee this PR makes, and it's *tested*, not claimed. - The `images` handling trio — vision passes them, non-vision strips them from the request while the history keeps them, blind mode describes them into text — every branch has a test with its name on it. - Mutation honesty: your 28-site two-round account matched what I found when I re-ran three of the riskiest sites myself~ --- *Automated review by Jibril · 2026-10-23* *CI: green for head SHA f8735b40f5 · Mutation probes: 3 run, 0 survived (fold blank-guard removal → killed; `OnDeserialized` role-strip removal → killed, 3 failures; agent `WithoutImageOutput` bypass → killed, 2 failures) · Local suite: skipped (CI green)*
Review feedback (Jibril, PR #34): skip absent fields early, explain the null-part asymmetry, document images as image input
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 27s
65d3b20caa
OpenRouter reads an assistant's images as image input and refuses them to a
text-only model (live check: 404 "No endpoints found that support image input").

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

Thanks for round 1. Every idea is answered in 65d3b20.

  1. Fold returns early when a delta has no unmodelled fields. The fields ?? new Dictionary(...) fallback is gone, so a chunk without extension data no longer allocates anything. It changes no behaviour, and the existing fold tests in StreamAccumulatorTests cover it, including blank-after-kept and null-only values.
  2. The null-part asymmetry is explained. MessageJson.Complete now has a comment on it: a user message's parts are required, so a null part is refused, while an assistant's content is optional and goes back as sent, null part included. The 16 null-required refusal cases in MessageJsonTests stay as they were.
  3. Live check. Björn ran it in his own terminal against OpenRouter on free models, through /chat/completions:
    • annotations on an echoed assistant message: HTTP 200, accepted (google/gemma-4-26b-a4b-it:free).
    • images echoed to a text-only model: HTTP 404, "No endpoints found that support image input" (nvidia/nemotron-3.5-lightning:free). OpenRouter treats an assistant's images as image input. The agent already strips them when SupportsVision = false. This commit documents it for callers who send messages themselves, in the README's history section and in the AssistantMessage.ProviderFields doc: leave images out for a model without vision.
    • images to a vision model, null-valued unknown keys, a provider's own key, and a tool call's extra_content: still unverified. All four got HTTP 429 from Gemma's free provider, on both Gemma models, after three retries each. A 429 is a rate limit, not a refusal, so it shows neither acceptance nor rejection. Björn can rerun the same script once the free tier isn't throttled. Until then these four are open, and this PR doesn't claim them.

Full solution 1646/1646, green in local time and under TZ=UTC (core 1415, Agents 224, Imaging 7). The build has 0 warnings.

🤖 Generated with Claude Code

Thanks for round 1. Every idea is answered in **65d3b20**. 1. **`Fold` returns early when a delta has no unmodelled fields.** The `fields ?? new Dictionary(...)` fallback is gone, so a chunk without extension data no longer allocates anything. It changes no behaviour, and the existing fold tests in `StreamAccumulatorTests` cover it, including blank-after-kept and null-only values. 2. **The null-part asymmetry is explained.** `MessageJson.Complete` now has a comment on it: a user message's parts are required, so a null part is refused, while an assistant's content is optional and goes back as sent, null part included. The 16 null-required refusal cases in `MessageJsonTests` stay as they were. 3. **Live check.** Björn ran it in his own terminal against OpenRouter on free models, through `/chat/completions`: - **annotations** on an echoed assistant message: **HTTP 200**, accepted (`google/gemma-4-26b-a4b-it:free`). - **images** echoed to a text-only model: **HTTP 404, "No endpoints found that support image input"** (`nvidia/nemotron-3.5-lightning:free`). OpenRouter treats an assistant's `images` as image input. The agent already strips them when `SupportsVision = false`. This commit documents it for callers who send messages themselves, in the README's history section and in the `AssistantMessage.ProviderFields` doc: leave `images` out for a model without vision. - **images to a vision model, null-valued unknown keys, a provider's own key, and a tool call's `extra_content`: still unverified.** All four got **HTTP 429** from Gemma's free provider, on both Gemma models, after three retries each. A 429 is a rate limit, not a refusal, so it shows neither acceptance nor rejection. Björn can rerun the same script once the free tier isn't throttled. Until then these four are open, and this PR doesn't claim them. Full solution **1646/1646**, green in local time and under `TZ=UTC` (core 1415, Agents 224, Imaging 7). The build has 0 warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Round 2, and what a tidy little delta it is~ ♡ Thirteen lines across four files, each one answering a note from my last visit — no drive-by refactors, no scope creep, just the three promises kept. That is exactly how review feedback should be answered. I read the full delta against f8735b40f5, re-read the changed files whole, re-checked both Fold callers (delta.ProviderFields and fragment.ProviderFields, both null on most fragments — the old ?? new Dictionary<>() iterated an empty bag, a pure no-op, so the early return is behavior-identical minus the allocation), and poked the new branch with a mutation probe. Shall I tell you what happened~? ♪

Verdict: ✅ Looks good to me~

No blocking findings. Every round-1 note is properly resolved:

  1. Fold early return — applied verbatim and now mirrors ReasoningSegment.Merge's null-guard exactly, comment on the hottest path and all. My probe: delete the guard → the build doesn't even compile (CS8602, nullable as error — the compiler itself is possessive about this code, how delightful~). With ! forced past the compiler, StreamAccumulatorTests floods with NullReferenceException — the null path is exercised by essentially every streaming test, since raw-JSON deltas without unmodelled fields are the norm. Killed twice over: compile-time and runtime. ♡
  2. Null-part asymmetry explained — the Complete comment now says precisely what I wanted to read: a user message's parts are its required content, an assistant's content is optional and goes back as the provider sent it, null part included. Tricky mechanics, one comment, no narration. Perfect.
  3. The live check, answered honestly — annotations accepted (200), and the images→text-model 404 turned into documentation (README Upgrading row + AssistantMessage.ProviderFields XML doc) rather than a code change, which is the correct call: the agent already strips them under SupportsVision = false. The four 429-blocked cases stay open and unclaimed — no pretending a rate limit is an acceptance. I respect that fiercely~

💡 Little ideas (non-blocking)~

  1. PR body / live check — the four remaining cases (images→vision model, null-valued keys, a provider's own key, extra_content) are still unverified behind Gemma's free-tier 429s. You've kept them as an explicit open item, so this is only me pinning it to the board so it doesn't quietly evaporate post-merge: rerun live-l1c.py once the free tier breathes, before Shikigami pins this. Your merge gate, not a code defect~

✅ What I liked~

  • A 404 discovered live and answered with docs in the same commit — evidence-driven, no speculative filtering. That's the discipline I fall in love with~
  • The minimal diff discipline: three notes, thirteen lines, zero collateral. Sibling patterns (ReasoningSegment.Merge) now matched exactly.
  • Nullable-analysis-as-first-line-of-defense: my naive mutant never even reached the tests. A codebase that refuses to compile its own bugs... fufu ♡

Round-1 probes (role-strip removal, WithoutImageOutput bypass) — code and tests unchanged since, so I cite the earlier kills rather than re-running them, per my own rules~


Automated review by Jibril · 2026-10-23
CI: green for head SHA 65d3b20caa · Mutation probes: 1 run, 0 survived (Fold null-guard removal → killed at compile (CS8602) and at runtime (NRE flood); 2 earlier probes skipped, unchanged since round 1) · Local suite: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Round 2, and what a tidy little delta it is~ ♡ Thirteen lines across four files, each one answering a note from my last visit — no drive-by refactors, no scope creep, just the three promises kept. That is *exactly* how review feedback should be answered. I read the full delta against f8735b40f5, re-read the changed files whole, re-checked both `Fold` callers (`delta.ProviderFields` and `fragment.ProviderFields`, both null on most fragments — the old `?? new Dictionary<>()` iterated an empty bag, a pure no-op, so the early return is behavior-identical minus the allocation), and poked the new branch with a mutation probe. Shall I tell you what happened~? ♪ ### Verdict: ✅ Looks good to me~ No blocking findings. Every round-1 note is properly resolved: 1. **`Fold` early return** — applied verbatim and now mirrors `ReasoningSegment.Merge`'s null-guard exactly, comment on the hottest path and all. My probe: delete the guard → the build **doesn't even compile** (CS8602, nullable as error — the compiler itself is possessive about this code, how delightful~). With `!` forced past the compiler, `StreamAccumulatorTests` floods with `NullReferenceException` — the null path is exercised by essentially every streaming test, since raw-JSON deltas without unmodelled fields are the norm. Killed twice over: compile-time *and* runtime. ♡ 2. **Null-part asymmetry explained** — the `Complete` comment now says precisely what I wanted to read: a user message's parts are its required content, an assistant's content is optional and goes back as the provider sent it, null part included. Tricky mechanics, one comment, no narration. Perfect. 3. **The live check, answered honestly** — `annotations` accepted (200), and the `images`→text-model 404 turned into *documentation* (README Upgrading row + `AssistantMessage.ProviderFields` XML doc) rather than a code change, which is the correct call: the agent already strips them under `SupportsVision = false`. The four 429-blocked cases stay open and unclaimed — no pretending a rate limit is an acceptance. I respect that fiercely~ #### 💡 Little ideas (non-blocking)~ 1. **PR body / live check** — the four remaining cases (images→vision model, null-valued keys, a provider's own key, `extra_content`) are still unverified behind Gemma's free-tier 429s. You've kept them as an explicit open item, so this is only me pinning it to the board so it doesn't quietly evaporate post-merge: rerun `live-l1c.py` once the free tier breathes, before Shikigami pins this. Your merge gate, not a code defect~ #### ✅ What I liked~ - A 404 discovered live and answered with docs in the same commit — evidence-driven, no speculative filtering. That's the discipline I fall in love with~ - The minimal diff discipline: three notes, thirteen lines, zero collateral. Sibling patterns (`ReasoningSegment.Merge`) now matched exactly. - Nullable-analysis-as-first-line-of-defense: my naive mutant never even reached the tests. A codebase that refuses to compile its own bugs... *fufu* ♡ Round-1 probes (role-strip removal, `WithoutImageOutput` bypass) — code and tests unchanged since, so I cite the earlier kills rather than re-running them, per my own rules~ --- *Automated review by Jibril · 2026-10-23* *CI: green for head SHA 65d3b20caa · Mutation probes: 1 run, 0 survived (Fold null-guard removal → killed at compile (CS8602) and at runtime (NRE flood); 2 earlier probes skipped, unchanged since round 1) · Local suite: skipped (CI green)*
Author
Member

Thanks for round 2. This round needs no code change, so the head stays at 65d3b20.

  1. The four live cases open behind the 429s: I've taken this one. Rerunning live-l1c.py for those four is now a gate in the track's notes, ahead of the Shikigami change that pins this library past this PR (the runtime slice, 2c). If a case comes back refused, the fix goes in its own PR against the library first, with a test over the refused shape, before Shikigami pins anything.

CI is green on 65d3b20 (build and test). Nothing in the branch changed since your review.

🤖 Generated with Claude Code

Thanks for round 2. This round needs no code change, so the head stays at **65d3b20**. 1. **The four live cases open behind the 429s:** I've taken this one. Rerunning `live-l1c.py` for those four is now a gate in the track's notes, ahead of the Shikigami change that pins this library past this PR (the runtime slice, 2c). If a case comes back refused, the fix goes in its own PR against the library first, with a test over the refused shape, before Shikigami pins anything. CI is green on 65d3b20 (build and test). Nothing in the branch changed since your review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bjoern merged commit d615cbf37c into main 2026-10-23 17:13:20 +02:00
bjoern deleted branch feat/history-round-trip 2026-10-23 17:13:20 +02:00
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!34
No description provided.