feat: a history stores and replays as it came #34
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/history-round-trip"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
ProviderFieldsProviderFields([JsonExtensionData]) is added onAssistantMessageandToolCall, and on their stream fragmentsAssistantDeltaandToolCallDelta. Fields the library does not model —annotations,images, a provider's own such as a tool call'sextra_content— are kept as the reply sent them, read whole or streamed, and sent back with the message on the next request.annotationsstreamed in pieces);nullnor blank;nullis kept as sent;roleis skipped.roleis its kind, not a field. A reply's message is read as anAssistantMessage, whereroleis no discriminator, so it would land among the extension data and go back as a secondrole.IJsonOnDeserializeddrops it, and a bag left empty reads asnull. The over-the-wire test caught this: every reply read whole would otherwise have been sent back with a duplicate key.Validaterefuses aProviderFieldskey named like a field already sent (an assistant message'srole,content,tool_calls, …; a call'sid,type,function,index), and a value that cannot be written, as it already does for plugin settings.MessageJsonSerialize(Message),Serialize(IReadOnlyList<Message>),DeserializeMessage(string)andDeserializeHistory(string), over the client's own settings. They use the same names, find therolewherever it sits in an object, and write it first.JsonException: not JSON,null, without aroleor with an unknown one, missing a required field, or holding one asnull(System.Text.Json checks only that a required field is present).A model without vision never gets a reply's image output
SupportsVision = false, an assistant message'simagesare left out of the request, and the history keeps them.ImageDescriber, they are described into the message's text, as a tool's images already are.Docs
UserMessage.Contentcorrected: it was always written as an array of parts.ToolCallequality,Validate;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
imagesare among these fields, except toward a model without vision. A caller strips them withmessage with { ProviderFields = null }, and a call's own withcall 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 fromOPENROUTER_API_KEY, never printed) sends six cases:null-valued keys;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:extra_content), tool;rolefound wherever it sits;rolekept nowhere else;null-required case;StreamAccumulatorTests:nullkept when onlynullarrived;roleskipped;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:imagesfield 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.
Validate;null-required refusals;Serializeexceptions documented;MessageJson,ToolCall,UserMessageand Upgrading wording;Mutation probes: 28 sites.
nulluser part;imagesfield that is no list.Notes
🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 98.1%
OpenRouter.Net.Agents - 91.6%
OpenRouter.Net.Imaging - 81.8%
🔮 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_contentand all~ ♡ This is precisely the kind of fidelity problem I adore sinking my teeth into. The wire-name collision guard inValidate, therole-is-a-kind-not-a-field insight, the stream fold that mirrorsReasoningSegment.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/ToolCallFieldscollision sets (all present —content,name,tool_calls,reasoning,reasoning_details,refusal,cache_control,role;id,type,function,index— nothing missing). I tracedRawContentsoContent/Partsshare one wire slot with no double-write. I confirmedContentPartListConverteralways wrote arrays on write, so theUserMessagedoc correction is a correction, not a wire change. I verifiedProviderFieldFoldclones everyJsonElementagainst document disposal whileToFieldsre-owns arrays viaSerializeToElement— careful~ And the accumulator's value-after-array / array-after-value edges are pinned by tests, not folklore.💡 Little ideas (non-blocking)~
Foldallocates a freshnew 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 siblingReasoningSegment.Mergeguards withif (fields is null) return;. A one-line early return would match it exactly~ ♪Completerefuses anullpart inside a user message's content (content: [null]) but lets an assistant message'scontent: [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~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~
IJsonOnDeserializedrole-strip is a genuinely elegant solve for the duplicate-rolehazard — and the over-the-wire test that caught it is exactly the kind of adversarial test that makes me feel warm~ ♡Validaterefusing aProviderFieldskey that "would reach the wire twice" closes the duplicate-key hole before it ever hits the wire. Pre-emptive. Possessive. I approve.RequestBodies[1] == RequestBodies[2], escapes included) is the single strongest guarantee this PR makes, and it's tested, not claimed.imageshandling 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.Automated review by Jibril · 2026-10-23
CI: green for head SHA
f8735b40f5· Mutation probes: 3 run, 0 survived (fold blank-guard removal → killed;OnDeserializedrole-strip removal → killed, 3 failures; agentWithoutImageOutputbypass → killed, 2 failures) · Local suite: skipped (CI green)Thanks for round 1. Every idea is answered in
65d3b20.Foldreturns early when a delta has no unmodelled fields. Thefields ?? new Dictionary(...)fallback is gone, so a chunk without extension data no longer allocates anything. It changes no behaviour, and the existing fold tests inStreamAccumulatorTestscover it, including blank-after-kept and null-only values.MessageJson.Completenow 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 inMessageJsonTestsstay as they were./chat/completions:google/gemma-4-26b-a4b-it:free).nvidia/nemotron-3.5-lightning:free). OpenRouter treats an assistant'simagesas image input. The agent already strips them whenSupportsVision = false. This commit documents it for callers who send messages themselves, in the README's history section and in theAssistantMessage.ProviderFieldsdoc: leaveimagesout for a model without vision.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
🔮 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 bothFoldcallers (delta.ProviderFieldsandfragment.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:
Foldearly return — applied verbatim and now mirrorsReasoningSegment.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,StreamAccumulatorTestsfloods withNullReferenceException— 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. ♡Completecomment 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.annotationsaccepted (200), and theimages→text-model 404 turned into documentation (README Upgrading row +AssistantMessage.ProviderFieldsXML doc) rather than a code change, which is the correct call: the agent already strips them underSupportsVision = 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)~
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: rerunlive-l1c.pyonce the free tier breathes, before Shikigami pins this. Your merge gate, not a code defect~✅ What I liked~
ReasoningSegment.Merge) now matched exactly.Round-1 probes (role-strip removal,
WithoutImageOutputbypass) — 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)Thanks for round 2. This round needs no code change, so the head stays at
65d3b20.live-l1c.pyfor 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