Skip to content

fix: JSON mode with stream: true sends its reply; nested objects stay whole - #235

Merged
solderzzc merged 1 commit into
SharpAI:mainfrom
HamedShams:fix/json-mode-stream-flush
Oct 16, 2026
Merged

solderzzc merged 1 commit into
SharpAI:mainfrom
HamedShams:fix/json-mode-stream-flush

Conversation

@HamedShams

Copy link
Copy Markdown
Contributor

Closes #233.

What

A JSON-mode stream now sends its reply. handleChatStreaming buffers JSON-mode output so a hallucinated prefix object can be dropped, and the buffer was only flushed from inside a chunk, once 32 chunks had arrived or a second { showed up; nothing flushed it when the stream ended. The generator holds a {-led reply as a possible bare tool call and hands it over as one chunk, so the thresholds never tripped and the client got an empty delta and [DONE].

Two changes in that block:

  • The flush is one function, flushJSONBuffer, called at the threshold as before and now also at the end of the stream: in case .info before the finish chunk, and in the post-loop safety net for a stream that ends without one.
  • The prefix strip is jsonModeFlushText: it drops a complete object that precedes a second one and otherwise sends the text whole. The old rule cut at the second { wherever it was, so {"user": {"name": "Bob"}} went out as {"name": "Bob"}}.

The thresholds, the stop-sequence handling and the non-streaming path are untouched. What the buffer holds is still not scanned for stop sequences before it goes out, same as before this change.

Why

Structured output is where streaming and JSON mode meet, and client.chat.completions.create(stream=True, response_format={"type": "json_object"}) returned nothing to iterate over. Repro and line references in #233.

Verified

  • JSONModeStreamTests (new) drives handleChatStreaming with a synthetic Generation stream, no model needed: a one-chunk reply reaches the client at .info and when the stream ends without .info; a nested object delivered per token is forwarded whole; a closed prefix object is still dropped; outside JSON mode nothing changes. The prefix rule has its own cases. On main, three of the five handler cases fail.
  • Contract Test 13 (new): JSON mode with stream: true assembles to the same content as the non-streaming reply. On main the stream is empty, so it fails there; green here.
  • swift test --filter SwiftLMTests: 326 tests green (M1 Max, Xcode 27 / Swift 6.4). tests/test-contract.sh with the CI model (mlx-community/Qwen2.5-0.5B-Instruct-4bit): 21 passed, 0 failed, 2 skipped.

…ts whole

handleChatStreaming buffers JSON-mode output so a hallucinated prefix
object can be dropped, and only flushed the buffer from inside a chunk,
once 32 chunks had arrived or a second "{" showed up. Nothing flushed it
when the stream ended. The generator holds a "{"-led reply as a possible
bare tool call and hands it over as one chunk, so the thresholds never
tripped and a JSON-mode stream carried no content at all.

The flush is now one function, called at the threshold as before and at
the end of the stream, in the .info case and in the post-loop safety
net. The prefix strip drops only a complete object that precedes a
second one; the old rule cut at any second "{" and turned
{"user": {"name": "Bob"}} into {"name": "Bob"}}.

JSONModeStreamTests drives the handler with a synthetic stream, and
contract Test 13 checks that a JSON-mode stream assembles to the
non-streaming reply.

Closes SharpAI#233.
@solderzzc
solderzzc merged commit 7a71d8b into SharpAI:main Oct 16, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON mode with stream: true returns no content: the buffered reply is never flushed when the stream ends

2 participants