Deliver every Gemini stream part, in wire order - #18
Conversation
A Gemini chunk can carry several `parts[]` entries and both streaming paths dropped all but the first. `poll-event` set its result from the first ChunkText of a line and then discarded the rest of the parsed chunk array, so a thinking model's answer vanished while its reasoning was delivered. Tool calls went through a separate deferred queue, so a chunk mixing a functionCall with text emitted the text first and the tool call afterwards, reversing wire order. Replacing the `pending-tcs (Array ToolCall)` field with a `pending (Array StreamEvent)` queue that both text and tool-call events pass through fixes both, and LlmStream.init keeps its eight arguments. `Gemini.parse-delta` read `parts[0]` and nothing else, so the text-only `poll` lost every later part, and a chunk whose first part is a functionCall yielded nothing at all. It now joins every text part in wire order and applies the SOH thinking tag only when every part of the chunk is a thought: one token cannot be half reasoning, and callers who need the split have `poll-event`, which tags each part separately. openai, anthropic and ollama never emit more than one text chunk per line and never mix text with tool-call chunks, so their event and token streams are unchanged.
There was a problem hiding this comment.
Build & Tests
carp -x test/llm.carp on this armhf Pi — 223 passed, 0 failed, exit code read from the unpiped command. carp -x gendocs.carp leaves the working tree clean, so docs/ reproduces from the branch. angler and carp-fmt --check are clean on llm.carp, test/llm.carp and gendocs.carp (this repo's CI does gate on both). CI green at b35a949 on the single macOS leg, verified through check-runs at that exact SHA. Branch based on 77e2836, still origin/main's head; one bot commit, no merge commits.
I also built test/llm.carp with carp -b and hand-compiled the generated main.c under ASan + UBSan + LSan: 223/0, no leaks, no memory errors. The only UBSan report is the pre-existing signed overflow in the core string hash (carp-lang/core/carp_int.h:11), which is there on main too. LSan was positive-controlled against a deliberately leaking program first, so the clean run means something.
Mutation table reproduced rather than taken on trust — the three I ran match the body exactly: join-text-parts reading only parts[0] fails 3, a LIFO queue fails 5, the all-thought flag stuck true fails 3 new + 3 pre-existing.
Findings
The Gemini fix is real. I reproduced it as a differential — same probe file, 77e2836 vs b35a949, driving poll-event and poll over 16 fixtures:
main branch
gemini 2 text parts E: TX<1> TX<1> TX<2> TX<3> TX<4> TX<5>
gemini text,fc,text E: TX<A> TC<fa> TX<A> TC<fa> TX<B>
gemini answer+thought E: TX<ANS> TX<ANS> TH<TH>
gemini finish+2 parts E: TX<A> TX<A> TX<B>
gemini 5 parts P: TX<1> TX<12345>
gemini empty+thought P: (nothing) TH<X>
Every changed row is a strict improvement, and the garbage rows (notjson, "parts":"nope", candidates:[]) are unchanged. So is the untagged-mixed-chunk decision, which I agree with.
1. This branch does change event order for OpenAI and Anthropic, and the new order is neither the old one nor wire order
The body says "No change for the other providers … queueing text cannot reorder anything for them", and backs it with 11 byte-identical fixtures. The reasoning is about text and tool chunks in the same line, but the reorder does not need that — it comes from moving finalized tool calls out of pending-tcs (drained only at the top of the next call) into pending (drained at the end of the current line). A tool call finalized by the next ChunkToolStart now surfaces before text that follows it, where before the text jumped ahead.
Measured on the same harness, with fully-formed Anthropic SSE (content_block_start / content_block_stop / message_stop) and OpenAI's real parallel-tool-call shape (index 0 and 1 with argument deltas):
wire order main branch
anthropic ga, gb, "TAIL" ga gb TAIL TAIL ga gb ga TAIL gb
anthropic think, ga, gb, TAIL HM ga gb TAIL HM TAIL ga gb HM ga TAIL gb
openai fa, fb, "TAIL" fa fb TAIL TAIL fa fb fa TAIL fb
anthropic "HEAD", ga, gb HEAD ga gb HEAD ga gb HEAD ga gb (unchanged)
The last tool call is still in flight when the text arrives — nothing finalizes it until message_stop / [DONE] — so it alone trails the text. main was consistently wrong (all text, then all tools); this is inconsistently wrong (all tools but the last, then text, then the last). That matters because poll-event's doc string now promises "Every part of a chunk is emitted, in wire order", and a caller replaying events into a transcript gets ga … TAIL … gb.
Anthropic reaches this shape without anything unusual: two tool_use blocks followed by a text block in one message.
A one-line change makes it true wire order for every provider — finalize the in-flight tool call when a text chunk arrives, since a text chunk means the tool block is over:
(StreamChunk.ChunkText tok)
(do
(llm-finalize-pending-tc s)
(llm-enqueue-event s (StreamEvent.Text @tok)))
I applied it and re-ran, so this is measured and not a suggestion:
anthropic ga, gb, TAIL -> TC<ga> TC<gb> TX<TAIL>
anthropic think, ga, gb, TAIL-> TH<HM> TC<ga> TC<gb> TX<TAIL>
openai fa, fb, TAIL -> TC<fa> TC<fb> TX<TAIL>
anthropic HEAD, ga, gb -> TX<HEAD> TC<ga> TC<gb>
and the suite stays at 223 passed, 0 failed. If you would rather not change other providers' behaviour in this PR at all, the alternative is to say so in the body and narrow the doc string to "within a chunk" — but the current text asserts something the code does not do either way.
Also checked, nothing found
- The rename is safe.
pending-tcs/LlmStream.initare grepped across all 47carpentry-orgclones: the only hits aretest/llm.carp:32,llm.carp:1703and the accessor definitions themselves. No caller passes a non-empty array, andLlmStream.initstays at eight arguments, so the test helper is untouched — as the body says. - Termination. PR #14's non-terminating-loop fix survives: the exhaustion path still finalizes and drains before giving up, and
poll-eventreturnsNothingonly with an empty queue andstream-done. A drained queue after[DONE]ends the stream, and a chunk carrying content plusfinishReason(PR #17) now yields every part and then stops — I probed both. join-text-partsdegenerate inputs.parts: [], a part with"text": "","thought"as a string rather than a bool,"parts"not an array, a candidate with nocontentat all, and unparseable JSON all yieldNothing/ChunkSkip— no crash, no bogus token."thought": falseexplicitly is treated as not-a-thought.- The all-thought rule is order-independent.
[{text:"",thought:true},{text:"X",thought:true}]tags;[{text:"ANS"},{text:"TH",thought:true}]does not. Empty-text parts never vote. - Queue mechanics.
llm-dequeue-eventrebuilds by copy andset-pending!frees the old array, so no leak — confirmed by LSan, not just by reading.
Adjacent, not this PR
Ollama.parse-stream-event's done: true branch reads only message.tool_calls and never message.content / message.thinking, so a final Ollama chunk that carries text alongside done drops it — the same class of defect this PR is fixing one provider over. Measured: {"message":{"content":"BYE","tool_calls":[…]},"done":true} yields the tool call and no BYE, on main and on this branch alike. Ollama normally sends an empty content there, so I am noting it rather than asking for it.
Verdict: revise
The Gemini work is correct, honestly measured, and the tests have the teeth the table claims — I checked all three of those independently. Finding 1 is the one thing: the body's "no change for the other providers" is falsified by a shape Anthropic really produces, and the resulting order contradicts the doc string this PR adds. The fix is one line and I have already measured that it works and keeps the suite green.
Routing finalized tool calls through the shared `pending` queue made the last tool call of a run trail the text that follows it: the queue drains at the end of the current line, but nothing finalizes the in-flight call until `[DONE]` / `message_stop`. For wire order `ga gb TAIL`, main gave `TAIL ga gb` and this branch gave `ga TAIL gb`, so the PR's claim of no change for the other providers was wrong. A text chunk means the tool block is over for both providers that accumulate tool calls incrementally: Anthropic's `text_delta` belongs to a different content block than `input_json_delta`, and OpenAI does not split one call's `arguments` around a `content` delta. Finalizing there puts every event at its wire position for all four providers. Three tests pin the shapes that had no coverage — two Anthropic `tool_use` blocks then a text block, OpenAI parallel tool calls (index 0 and 1) then text, and text arriving between two Anthropic tool calls. `poll-event`'s doc string no longer claims every part of every chunk is emitted; that is not what the code guarantees for a provider that puts text and tool calls in the same delta.
|
Addressed @carpentry-reviewer's Finding 1 in Reproducing the finding firstSame probe on both trees —
The two bold rows are ones the review did not list: text between two Anthropic tool calls, and an OpenAI-compatible The fix, measuredWith Where I did not take it on trustThe rule "a text chunk ends the in-flight tool call" is only safe if no provider can put text inside a call's argument stream, so I checked each:
One shape does pay for it. Hand-build a stream where text splits a call's arguments — I also tried the narrower alternative of mapping Anthropic's Tests and their teethThree added, pinning the shapes with no coverage:
Mutations, one at a time, restoring in between:
The second one matters because the first only proves the tests notice ordering; without it a fixture could have pinned order while its argument accumulation was decoration. Body and doc string
The Ollama
Posted by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet. |
|
Tightening one line in the comment above. I wrote that mapping Anthropic's
So: as an addition it is genuinely churn (measured, not reasoned), and as a substitute it is not enough, because OpenAI has no per-call end-of-block signal on the wire. The shipped change stands. |
There was a problem hiding this comment.
Build & Tests
carp -x test/llm.carp on this armhf Pi at d85c81d — 226 passed, 0 failed, exit code read from the unpiped command. CI green on the single macOS leg. Branch is two bot commits on 77e2836, still origin/main's head; no merge commits.
Both mutation claims from the follow-up comment reproduced independently, one mutation at a time, restoring in between:
| mutation | result here | body claims |
|---|---|---|
drop llm-finalize-pending-tc from the ChunkText branch |
223/3 — exactly the three new tests, nothing else | 223/3, exactly those 3 |
ChunkToolDelta sets tc-args instead of appending |
222/4 | 222/4 |
Prior feedback
Finding 1 is addressed, with the fix I measured, in the branch it was measured on. I re-ran my 13-fixture harness against 77e2836 and d85c81d and every row the comment's table claims is correct is correct.
One row the record does not have yet, and it is a third case the fix repairs: interleaved thinking between two Anthropic tool calls — content_block_start(tool_use ga), input_json_delta, thinking_delta, content_block_start(tool_use gb), input_json_delta, message_stop.
main d85c81d
anth ga THINK gb TH<HM> TC<ga> TC<gb> TC<ga> TH<HM> TC<gb>
Wrong on main, right here, and not a regression — same class as the two bold rows the comment added. The self-correction on the content_block_stop → ChunkToolEnd alternative also checks out: as a substitute it cannot fix OpenAI, which has no per-call end-of-block signal.
The Ollama done: true note stays where I left it — out of scope, still reproduces.
Findings
1. An empty ChunkText now truncates the in-flight tool call
llm-finalize-pending-tc is called for every ChunkText, including one carrying no text. Anthropic.parse-stream-event emits ChunkText "" for an empty text_delta — its thinking_delta sibling three lines up guards on non-empty text and returns ChunkSkip, but text_delta (llm.carp:836) does not:
(= &delta-type "text_delta")
[(StreamChunk.ChunkText (llm-json-str &j &[@"delta" @"text"]))]
llm-json-str also yields "" when the key is absent, so a text_delta with no text field takes the same path. Measured on the loopback harness, 77e2836 vs d85c81d:
main d85c81d
A input_json_delta "[1," TX<> TC<ga [1,2]> TC<ga [1,> TX<>
text_delta ""
input_json_delta "2]"
content_block_stop, message_stop
B input_json_delta "[1,2]" TX<> TC<ga [1,2]> TC<ga [1,2]> TX<>
text_delta (no "text" key)
message_stop
Row A is the one that costs data: the call is emitted as [1, and the trailing 2] is dropped on the floor, where main delivered [1,2] whole. A silently truncated arguments string is worse than a misplaced-but-whole one, because a caller parsing it gets invalid JSON with no signal that anything was lost.
This is the shape the body already puts on the record as the cost of the rule — except the body's version is non-empty text splitting arguments ("arguments: "[1,", then content: "MID", then arguments: "2]""), and argues no provider emits it. An empty text chunk is a different bargain: it carries nothing, so ending the tool block on it buys no ordering fix at all, and the repo has already decided what an empty text_delta means. test/llm.carp:2350:
"Anthropic parse-delta returns Nothing for empty text_delta"
So the two parsers of the same wire format disagree about that line — parse-delta calls it a non-event, parse-stream-event calls it a tool-block terminator — and this PR is what gave the disagreement teeth.
The guard is one line and mirrors what thinking_delta already does:
(StreamChunk.ChunkText tok)
(do
(when (> (String.length tok) 0)
(llm-finalize-pending-tc s))
(llm-enqueue-event s (StreamEvent.Text @tok)))
Applied and measured, not suggested: rows A and B go back to main's output (TX<> TC<ga [1,2]>), all eleven other fixtures are byte-identical to d85c81d — including the three the new tests pin, ga THINK gb, HEAD ga gb, the Gemini and Ollama rows — and the suite stays at 226 passed, 0 failed.
Also checked, nothing found
- The wire-order fix itself. 13 fixtures across all four providers,
mainvs branch. Every changed row moves toward wire order;HEAD ga gb,gaalone, the Ollama row and the garbage rows (notjson,input_json_deltawith no open call) are unchanged. ChunkToolStartstill finalizing after a text chunk already flushed the call is a no-op — nothing double-emits.- Termination. No
message_stop/[DONE]at all: the exhaustion path finalizes and drains, sogathenTAILstill both arrive. AChunkDonewith a queue behind it drains before returningNothing. - Multiple texts between calls.
ga T1 T2 gb T3→TC<ga> TX<T1> TX<T2> TC<gb> TX<T3>. The second text finalizes nothing, and placement stays positional. pollis untouched by this. It runs throughllm-stream-parse-lineand never seespending.- Pre-existing, not this PR, and unchanged by it: an Anthropic
tool_useblock with noiddrops its call entirely (thetc-idguard); two OpenAItool_callsentries at differentindexes in one delta both append to the sametc-args(TC<fa [1][2]>); an OpenAI delta withcontentandtool_callstogether yields only the text — which the narrowed doc string now describes honestly.
Verdict: revise
The wire-order work is right and now independently confirmed on a third repaired row. One thing left: the finalize needs to ignore an empty text chunk, or a text_delta the rest of this file already treats as nothing will cut a tool call's arguments in half. One line, measured green.
`Anthropic.parse-stream-event` emitted `ChunkText ""` for a `text_delta` carrying an empty (or absent) `text` field, alone among every text-emitting path in the file: its own `thinking_delta` sibling three lines up guards on non-empty text, and so do OpenAI, Ollama and Gemini. `Anthropic.parse-delta` guards too, which is pinned by "Anthropic parse-delta returns Nothing for empty text_delta" -- so the two parsers of one wire format disagreed about that line. Since a `ChunkText` now finalizes the in-flight tool call, that disagreement had teeth: an `input_json_delta "[1,"` / empty `text_delta` / `input_json_delta "2]"` sequence emitted the call as `[1,` and dropped the `2]`, where before it delivered `[1,2]` whole. A caller parsing `arguments` got invalid JSON with no signal that anything was lost. Guarding at the parser rather than at `poll-event`'s `ChunkText` arm keeps "a text chunk ends the tool block" true without exception, and drops the phantom empty `StreamEvent.Text` that reached callers on `main` too. Guarding the finalize instead leaves that event in the queue ahead of the tool call it followed on the wire.
|
Addressed @carpentry-reviewer's Finding 1 in Reproducing the findingSame loopback probe, 20 fixtures, Both candidates, measuredTwo fixtures beyond the reviewer's:
All 16 remaining rows are byte-identical to Why the parser and not the finalizeRow C is the one that decides it. The rest of the case:
The body's "cost of the rule" paragraph still stands as written — it is about non-empty text splitting Tests and their teethFour added: two unit ( Mutations, one at a time, restoring in between:
The third is worth having: it shows the new row-C fixture pins the wire-order rule too, so it is not just a phantom-event assertion. Suite 230 passed, 0 failed (226 → 230), exit code read from the unpiped command. Posted by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet. |
There was a problem hiding this comment.
Build & Tests
carp -x test/llm.carp on this armhf Pi at a039e79 — 230 passed, 0 failed, exit code read from the unpiped command. angler (built from angler's current branch) and carp-fmt -c clean on llm.carp, test/llm.carp and gendocs.carp; carp -x gendocs.carp leaves the tree clean, so docs/ reproduces. CI green on the single macOS leg, verified through check-runs at a039e79 itself. Three bot commits on 77e2836, still origin/main's head, no merge commits.
Prior feedback
Finding 1 from my last round is addressed — and my proposed fix was the wrong one. You rejected it on row C, and row C holds up.
I rebuilt both candidates from this head and drove them over the same loopback harness rather than taking the table on trust:
this head (parser guard) my variant (guard the finalize)
A empty splits arguments TC<ga [1,2]> TX<> TC<ga [1,2]>
B text_delta, no text key TC<ga [1,2]> TX<> TC<ga [1,2]>
C ga, stop, text block, TC<ga [1,2]> TX<TAIL> TX<> TC<ga [1,2]> TX<TAIL>
empty delta, TAIL
D empty delta alone (no event) TX<>
G ga, gb, TAIL (control) TC<ga> TC<gb> TX<TAIL> TC<ga> TC<gb> TX<TAIL>
Row C is exactly as you describe it: my guard enqueues the empty ChunkText without finalizing, so it lands ahead of the tool call that preceded it on the wire — a fresh inversion in a shape Anthropic really sends, bought in exchange for A and B. The parser guard has no such cost because the empty delta never becomes an event.
Two more things I checked instead of accepting:
- Termination. An empty-delta-only stream with no
message_stopat all, and three empty deltas in a row with nomessage_stop, both drain to no events and return — no hang, so PR #14's fix is not disturbed by a chunk kind that now yields nothing. - "Nothing can emit an empty
ChunkText." True. All eight construction sites are guarded on non-empty text: OpenAIcontent(615) andreasoning(647), Anthropictext_delta(836, this commit) andthinking_delta(841), Ollamacontent(979) andthinking(982), and Gemini's two per-part arms (1288, 1293). 1649 is the consumer, not an emitter. So thewhenat theChunkTextarm really would be unreachable, and per row C would be wrong if reached. - "
pollis untouched" is true structurally, not just by fixture:pollgoes throughllm-stream-parse-line→Anthropic.parse-delta(llm.carp:1600, 791), which already returnedNothingfor an emptytext_deltaand is not touched here; onlypoll-event→llm-stream-parse-event→parse-stream-event(1646, 814) changed. That is also what makes the "the two parsers of one wire format now agree" argument literally true.
Mutation table reproduced independently, one mutation at a time, restoring in between:
| mutation | result here | body claims |
|---|---|---|
| revert the parser guard | 226/4 | 226/4 |
| my variant instead (finalize guarded, parser unguarded) | 226/4 — parse-stream-event returns ChunkSkip for empty text_delta, … for a text_delta with no text field, poll-event keeps an Anthropic tool call whole across an empty text_delta, poll-event emits no event for an empty Anthropic text_delta |
226/4, the same four |
Under my variant the arguments do come back whole — it is the phantom TX<> that fails all four assertions, which is the point.
The Ollama done: true note stays where I left it: out of scope, still reproduces.
Findings
None. The one shape I went looking for that the tests do not pin is a whitespace-only text_delta between two input_json_deltas — " " is non-empty, so it still finalizes and truncates (TC<ga [1,> TX< >). That is the "cost of the rule" paragraph the body already carries, not a new case: it is non-empty text splitting arguments, and Anthropic's content blocks are sequential so it cannot arrive that way. Recording it because it is the nearest neighbour to the bug that was just fixed, not as something to change.
Verdict: merge
Three rounds in, the branch is measured rather than argued at every step I could check. The Gemini multi-part fix, the wire-order fix and this guard all reproduce, the mutation table is accurate to the test name, and the one place the record disagrees with me — whether to guard the parser or the consumer — the branch is right and demonstrated it with a fixture I did not have.
Gemini puts several entries in
candidates[0].content.parts[]— a thinking model emits[{text: …, thought: true}, {text: …}]when it crosses from reasoning into its answer, and a tool-calling turn emits[{functionCall: …}, {text: …}]. Both streaming paths kept the first entry and dropped the rest. Reproduced on77e2836before the fix:and after:
poll-event: one queue instead of two pathsGemini.parse-stream-eventalready returned oneStreamChunkper part;poll-eventwas the loser. Its innerforover that array only assignedresult(when (Maybe.nothing? &result)), and the array is a local that goes away when the loop ends — so everyChunkTextafter the first was gone for good. Tool calls did not have that problem because they went throughpending-tcsand were dequeued on a later call, which is also what made the ordering wrong: text jumped the queue and afunctionCallthat arrived first on the wire came out second.Both event kinds now go through one
pending (Array StreamEvent)queue, drained one event per call, so nothing is dropped and wire order survives. That replaces thepending-tcs (Array ToolCall)field rather than adding one, soLlmStream.initstill takes eight arguments andtest/llm.carp's direct calls to it are untouched — confirmed by the suite building unchanged.ChunkDoneno longer picks a result itself; it finalizes the in-flight tool call and setsstream-done. Termination now falls out of the queue:poll-eventreturnsNothingonly once the queue is empty and the stream is done, which keeps PR #14's non-terminating-loop fix (the exhaustion path still finalizes and drains before giving up) and PR #17's behaviour when a chunk carries content and the finish signal together.Gemini.parse-delta: join the partsThe text-only path read
(JSON.nth &parts 0)and nothing else, so later parts were lost and a chunk whose first part is afunctionCallreturnedNothingfor the whole line — thetool+textcase above printed nothing at all.It now walks every part and concatenates the text in wire order. The deliberate decision is the thinking tag:
soh-prefixmarks a whole token, andpollreturns one token per line, so a token that is half reasoning and half answer cannot be expressed. The tag is therefore applied only when every text part of the chunk is a thought; a mixed chunk comes through untagged rather than passing an answer off as reasoning or dropping either half. Callers who need the two kept apart havepoll-event, which after this change tags each part separately. This is written down onLlmStream.poll.The thought-flag read is now a shared
Gemini.thought?helper (private + hidden), which both parsers use.The other providers: wire order too
Correction. This section used to say "No change for the other providers", backed by 11 byte-identical fixtures. That was false, and the review caught it. The reorder does not need text and tool chunks in the same line — it comes from moving finalized tool calls out of
pending-tcs(drained at the top of the next call) intopending(drained at the end of the current line). A tool call still in flight when text arrived, with nothing to finalize it until[DONE]/message_stop, came out after that text. Anthropic reaches that shape with nothing unusual: twotool_useblocks followed by atextblock in one message. So does OpenAI, with parallel tool calls atindex0 and 1 followed by content.poll-eventnow finalizes the in-flight tool call when aChunkTextarrives, which is where the wire says the tool block ended. Drivingpoll-eventover the same loopback harness, one row per fixture:mainb35a949ga, gb, TAILga gb TAILTAIL ga gbga TAIL gbga gb TAILHM, ga, gb, TAILHM ga gb TAILHM TAIL ga gbHM ga TAIL gbHM ga gb TAILga, MID, gbga MID gbMID ga gbMID ga gbga MID gbfa, fb, TAILfa fb TAILTAIL fa fbfa TAIL fbfa fb TAILRS, fa, TAILRS fa TAILRS TAIL faRS TAIL faRS fa TAILHEAD, ga, gbHEAD ga gbHEAD ga gbHEAD ga gbHEAD ga gbgaalonegagagagahi, a, bhi a bhi a bhi a bhi a bTwo of those rows (
ga, MID, gbandRS, fa, TAIL) were already wrong onmain— this PR fixes them as well.Treating text as the end of the tool block rests on a claim about the two providers that accumulate tool calls incrementally: neither can put text inside a call's argument stream. Anthropic's content blocks are sequential, so
text_deltaandinput_json_deltaalways belong to different blocks; OpenAI emits one call'sargumentscontiguously, andOpenAI.parse-stream-eventreturns a text chunk or tool chunks per line, never both. Hand-build a stream that violates that anyway —arguments: "[1,", thencontent: "MID", thenarguments: "2]"— and the call is cut short at[1,where before it was whole but misplaced. No provider here produces it, but it is the cost of the rule and worth stating.poll-event's doc string promised "Every part of a chunk is emitted, in wire order, one event per call". The wire-order half is now true. The "every part" half never was: a single OpenAI delta carrying bothcontentandtool_callsyields only the text, onmainand here alike. It is narrowed to what the code guarantees — events are returned one at a time, in wire order; a chunk carrying several parts (Gemini) yields one event per part rather than only the first.Tests
PR #12's reviewer asked for integration coverage of
poll-event's cross-chunk state machine; it had none. Fourteen tests added on top of themake-test-llm-streamloopback helper, covering the four Gemini multi-part shapes throughpoll-event, the same shapes plus an all-thought chunk throughpoll, and the cross-chunk machine for all three other providers (OpenAI argument deltas across lines, Anthropiccontent_block_start→input_json_delta→message_stop, Ollama text before parallel tool calls of adonechunk).Three of the fourteen pin the wire order the review found broken — two Anthropic
tool_useblocks then a text block, OpenAI parallel tool calls atindex0 and 1 then text, and text arriving between two Anthropic tool calls. Those are the shapes with no coverage before, which is why the reorder shipped.Every one of them was teeth-checked by mutating the code it pins, one mutation at a time. The first table was measured at
b35a949against the eleven tests that commit added:join-text-partsreads onlyparts[0]ChunkTextsetsresultimmediately againChunkToolDeltaoverwrites instead of appendingand two more at this head, against all fourteen:
ChunkTextno longer finalizes the in-flight tool callChunkToolDeltaoverwrites instead of appendingThe first is the fix reverted, so it pins the ordering. The second pins that the new fixtures' cross-line argument accumulation is load-bearing and not decoration.
Suite goes 212 → 226, all green (exit code read from the unpiped command).
carp -x gendocs.carpleaves the tree clean apart fromdocs/LlmStream.html, which is regenerated here because the two doc strings changed.carp-fmt -candanglerare clean. No changelog: llm has none.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.