Emit ChunkDone when a stream event carries content and the finish signal - #17
Conversation
StreamChunk.ChunkDone is what makes LlmStream.poll-event set stream-done and short-circuit its next call. Two parsers dropped it whenever the provider put content and the terminator in the same event, so the consumer went back to ResponseStream.poll after the model had already finished -- a wasted round trip on a closing connection, a block on a keep-alive one. Gemini.parse-stream-event consulted has-finish only in the arms where parts was missing or empty, so a candidate carrying both text (or functionCall) and finishReason returned just the content chunks. Ollama.parse-stream-event put ChunkDone in the else-branch of the tool-call check, so done:true with tool_calls returned only ChunkToolFull values. Both now append the terminator to whatever content they collected. The content-free paths are unaffected: pushing onto an empty array yields exactly the single-element [ChunkDone] those branches produced before. The consumer already handles this array shape and is unchanged; llm-finalize-pending-tc is a no-op unless a ChunkToolStart is mid-flight, so the appended terminator cannot fabricate an empty tool call. The existing "returns all tool calls from done message" assertion pinned the missing terminator via its length check; it now expects the terminator too.
There was a problem hiding this comment.
Build & Tests
Checked out c6e60d7. Merge-base is b29645b = current origin/main.
carp -x test/llm.carpon this branch — 212 passed / 0 failed, matching the PR body exactly.- CI
test (macos-latest)— pass, run'shead_shaconfirmed asc6e60d7. - The four assertions have teeth. I put
main'sllm.carpunder this branch's test file and re-ran: 208 passed / 4 failed, and the four are precisely the ones the body names —Nothing else moved, so the tightened length check really was pinning the missing terminator and the three new ones cannot pass without the fix.Test 'Ollama parse-stream-event appends ChunkDone to tool calls on done' failed Test 'Gemini parse-stream-event appends ChunkDone to text with finishReason' failed Test 'Gemini parse-stream-event appends ChunkDone to functionCall with finishReason' failed Test 'Ollama parse-stream-event returns all tool calls from done message' failed
I also drove the change through the wiring rather than stopping at the parser, using this file's own make-test-llm-stream harness. One Gemini event carrying both text and finishReason, one poll-event:
main |
this branch | |
|---|---|---|
| event returned | Text:hi |
Text:hi |
stream-done afterwards |
false |
true |
So the described benefit is real and observable end to end: the text still arrives, and the next poll-event short-circuits instead of going back to ResponseStream.poll.
Findings
No bugs. Both edits are correct — I checked every branch rather than the two the tests cover.
- Gemini. Old
condvs newwhen+ifagree on all four combinations: content + no finish → content (unchanged); no content + finish →[ChunkDone](unchanged, because pushing onto the empty array gives exactly that one element); no content + no finish →[ChunkSkip](unchanged); content + finish → content thenChunkDone(the fix). It is inside alet-do, so the trailingifis the value and is not one of the body forms a plainletwould silently drop. - Ollama. Same reasoning one level simpler:
Array.push-backon the result ofcopy-mapyields[ChunkDone]whentcsis empty, so the content-free path is byte-for-byte what it was. poll-eventcomposes correctly for both new shapes.[ChunkText, ChunkDone]: the text setsresult, then theChunkDonearm setsstream-doneunconditionally but skips claimingdonebecauseresultis alreadyJust— so the token is returned, not swallowed.[ChunkToolFull…, ChunkDone]: each tool call lands inpending-tcs,ChunkDonedequeues the first, and the drain at the top of the next call yields the rest before thestream-doneshort-circuit fires. No tool call is lost and none is fabricated.parse-deltareally is unaffected, as claimed —LlmStream.pollgoes throughllm-stream-parse-line, notparse-stream-event, so the text-only path never sees the new terminator.
1. The Gemini stream now stops at the first finishReason — worth stating out loud
This follows from the fix and is almost certainly what you want, but it is a behaviour change beyond "a terminator is appended", and the body does not say so. Measured on a stream whose finishReason event is followed by another content event:
main |
this branch | |
|---|---|---|
poll-event #1 |
Text:hi |
Text:hi |
poll-event #2 |
Text:MORE |
Nothing |
Against the documented Gemini contract this is the improvement — finishReason marks the end of generation, so content after it should not exist and terminating there is more correct than draining it. I could not construct a real Gemini response that hits it. But it is the one input where the two trees disagree about content rather than about a wasted round trip, so it belongs in the description rather than being discovered later.
2. The fix's actual consequence is not pinned, and the harness for it is in the same file
All three new assertions are parse-level. The thing the PR is for — stream-done being set so the consumer stops polling — is only reached by composition, and nothing asserts it. make-test-llm-stream is right there and already used two assertions further down (test/llm.carp:1853, 1886), so this is about three lines:
(assert-true test
(let-do [s (make-test-llm-stream "gemini"
@"data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hi\"}]},\"finishReason\":\"STOP\"}]}
")
e1 (LlmStream.poll-event &s)
done @(LlmStream.stream-done &s)]
(LlmStream.close s)
done)
"a Gemini event with content and finishReason ends the stream")
That is the shape I ran to produce the false -> true table above, so I know it compiles, passes here and fails on main. Not blocking — the parsers are where the change is and they are well covered — but it is the assertion that would catch a future refactor of poll-event's ChunkDone arm, which the parse-level tests would sail straight through.
3. The new one-tool-call Ollama assertion duplicates a fixture rather than tightening it
test/llm.carp:1610 uses the same fixture string, character for character as the existing assertion at 1601 ("returns ChunkToolFull for done with tool calls"), and its checks are a strict superset of it. For the two-tool-call case the PR tightened the existing assertion in place — which is the better move and is what makes the "changed one existing assertion" disclosure meaningful. Doing both ways in one PR leaves a test that can now never fail alone. Cosmetic.
4. Adjacent, pre-existing, one line from what you touched
An Ollama done: true message that carries content and no tool calls drops the content:
Ollama.parse-stream-event "{\"message\":{\"role\":\"assistant\",\"content\":\"tail\"},\"done\":true}"
-> 1 chunk: [ChunkDone]
Identical on main and on this branch, so it is not a regression — but it is the same bug this PR exists to fix ("content and the finish signal in the same event"), in the same if the Ollama hunk edits, and the branch never reads message.content. In Ollama's documented streaming protocol the final message's content is empty, so I could not show it mattering in practice, which is why I am not asking for it here. Flagging it as a separate small topic rather than scope creep on this one.
Verdict: merge
The premise is real, the fix is minimal and correct on every branch I could enumerate, and the tests are not vacuous — I proved that by running them against main's parsers and getting exactly the four expected failures and no others. The stream-done false -> true measurement confirms the behaviour the PR claims, end to end through poll-event. Nothing here blocks; the one thing I would ask for before merging is a line in the description about finding 1, since it is the only input where the two trees disagree about content.
StreamChunk.ChunkDoneis what makesLlmStream.poll-eventsetstream-doneand short-circuit the next call. Two parsers dropped it whenever the provider
put content and the finish signal in the same event, so the consumer went
back to
ResponseStream.pollafter the model had already finished — a wastedround trip on a closing connection, a block on a keep-alive one.
Gemini (
Gemini.parse-stream-event):has-finishwas only consulted in thearms where
partswas missing or empty, so a candidate carrying bothtext(or
functionCall) andfinishReasonreturned just the content chunks. Therepo's own
parse-responsefixture (test/llm.carp:159) is exactly that shape.Ollama (
Ollama.parse-stream-event):done: truewithtool_callsreturned only the
ChunkToolFullvalues; theChunkDonewas the else-branch ofthat same
if.Both now emit the content chunks followed by
ChunkDone. Neither changeaffects the content-free paths: pushing onto an empty array yields exactly the
single-element
[ChunkDone]those branches produced before.No plumbing changed.
LlmStream.poll-eventalready handles this array shape —ChunkTextsets the result only if it is stillNothing, and theChunkDonearm sets
stream-doneunconditionally while only claimingdonewhen noresult has been produced yet.
llm-finalize-pending-tcis a no-op unless aChunkToolStartis mid-flight, so the appended terminator cannot fabricate anempty tool call.
parse-deltais untouched — the text-only poll path has no Done channel.Tests
Three new parse-level assertions pin the array shape (length plus both
positions):
text+finishReason→ChunkTextthenChunkDonefunctionCall+finishReason→ChunkToolFullthenChunkDonedone: true+tool_calls→ChunkToolFullthenChunkDoneOne existing assertion changed: "Ollama parse-stream-event returns all tool
calls from done message" asserted
(= (Array.length &chunks) 2), which pinnedthe missing terminator. It now expects 3 and checks that index 2 is
ChunkDone; the two tool-call positions it already checked are untouched.Every other stream assertion passes unchanged, including the three Gemini
finishReasonones and"returns all functionCall parts"(that fixture has nofinishReason, so its length stays 2).Verified by reverting each fix on its own and re-running the suite (
mainbaseline: 209 passed / 0 failed; with this branch: 212 / 0):
"returns all tool calls from done message"Nothing else moved in either run.
No CHANGELOG in this repo, and no doc strings changed, so
docs/is unchanged.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.