feat(chat): stream the answer as the model writes it - #268
Merged
Merged
Conversation
Responses did not actually stream. Both chat clients already consumed a real token stream from their runtime, joined it into a complete string and returned that; the API runner then sliced the finished string into 80-character pieces and published those as "delta" events. The tokens arrived on time and were thrown away. On a local model at a few tokens a second that meant a spinner for 60-100 seconds and then the whole answer at once -- while README.md has claimed streaming since the rewrite. Make it real, end to end: - ChatClient gains an optional on_delta(kind, text) callback, implemented by the Ollama and llama.cpp clients from the loops that were already reading the stream, and forwarded by RoutingChatClient. Both clients take their streaming path when either cancellation or on_delta is wanted; title and translation calls stay single-shot. - SynthesisAgent passes it to the main turn only -- never the repair, translation or title calls -- so no foreign text can interleave. - GenerationService publishes each piece as a typed content_delta / thinking_delta progress event, and marks the result streamed. - The API runner skips its replay loop for a streamed result, so the answer is never sent twice. A non-streaming engine keeps the replay, so client-side rendering is identical either way. Two things this had to get right. EnvelopeStreamFilter (new) withholds anything that might be a block _parse_and_clean_response strips from the finished reply. Without it the user watches a memory-command JSON blob, a code-execution request, a legacy <memo> tag, or -- worst -- an entire private reasoning trace type itself into the answer bubble and then vanish when the cleaned answer replaces it. Matching is case-insensitive because the cleaner's own patterns are: a model gets tag case wrong often enough that an uppercase <MEMORY_COMMAND> is still parsed and executed. Its test compares what streams against what the cleaner actually returns, rather than grepping for tag names, because a name-based check only ever finds the patterns the filter already knows and so passes by construction. Deltas are coalesced by size or age. One event per token is ~20x the event volume of the old replay and the job registry retains a bounded number per job. The rule is checked as each piece arrives, so no timer thread is needed and a slow model is not delayed: measured over a 500-token answer, 100 tokens/second emits 59 events instead of 500, while 5 tokens/second -- a typical local model -- still sends every token on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dovvnloading
added a commit
that referenced
this pull request
Sep 12, 2026
Self-review of #268 found that every test of EnvelopeStreamFilter exercised it directly. Removing the wiring in SynthesisAgent.generate -- handing the raw callback to the chat client instead of the filtered one -- left the whole suite green while every control envelope reached the user. Verified by mutation: that change previously passed all 1022 tests, and now fails 12. The property that matters is not "the filter works", it is "what the user watches is what the finished answer contains". These drive the real agent over the real Ollama client with the reply delivered in small pieces, and assert the streamed content equals the cleaned answer -- for every shape _parse_and_clean_response strips, plus the reasoning-trace case on its own, which is the one that would put the model's private reasoning in the answer bubble. Also renamed test_an_engine_that_streams_only_whitespace_is_not_called_streamed: it passes an empty string, not whitespace, and the distinction matters -- whitespace is real text the model wrote and does stream. The name claimed the opposite of the behaviour. Two other properties were mutation-checked and already held: the coalescer flushing buffered text on the failure path, and streamed reflecting only deltas that were actually published. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Responses did not stream. Both chat clients already consumed a real token
stream from their runtime, joined it into a complete string and returned that;
the API runner then sliced the finished string into 80-character pieces and
published those as
generation.content_deltaevents. The tokens arrived ontime and were discarded.
On a local model at a few tokens per second that is a spinner for 60-100
seconds followed by the entire answer at once.
README.mdhas advertisedstreaming since the rewrite, and
llamacpp/chat_client.py's own moduledocstring said the opposite in writing: "Cortex never actually streams tokens
from the model runtime itself ... the SSE 'typing' effect is Cortex chunking the
already-complete response after the fact."
Nothing about the runtimes needed to change. The plumbing was already there.
What changed
ChatClienton_delta(kind, text),kindincontent/thinkingOllamaChatClient,LlamaCppChatClienton_deltais wantedRoutingChatClientSynthesisAgentGenerationServicecontent_delta/thinking_deltaprogress; marks the resultstreamedapi/routes.pyA non-streaming engine keeps the replay, so client-side rendering is identical
either way and the deterministic test double still exercises that path.
The two things this had to get right
1. Control blocks must not appear and then vanish.
_parse_and_clean_responsestrips four things from the finished reply: a<memory_command>proposal, a<code_execution_request>block, legacy<memo>/<clear_memory/>tags, and an inlineThinking... ...done thinking.trace. Streaming raw tokens shows the user text the final answer does not
contain, which then blinks out when the cleaned answer replaces it.
EnvelopeStreamFilter(new) withholds from the first character that couldbegin one of those, then drops the block once confirmed or releases the held
text once ruled out. Matching is case-insensitive, because the cleaner's
own patterns are: a model gets tag case wrong often enough that
IGNORECASEisdeliberate there, and an uppercase
<MEMORY_COMMAND>is still parsed andexecuted. Its design rule is that over-showing is visible and permanent while
under-showing self-heals, so anything still held when the model stops is
dropped rather than revealed.
2. Event volume. One SSE event per token is roughly 20x the old replay, and
the job registry retains a bounded number of events per job. Deltas are
coalesced by size or age, checked as each piece arrives so no timer thread is
needed. Measured over a 500-token answer: 100 tokens/second emits 59 events
instead of 500; 5 tokens/second -- a typical local model -- still sends every
token on its own. It is a ceiling on the fast path, not a delay on the slow one.
Review, and what it caught
Six independent reviewers audited the staged diff under separate lenses
(threading, filter correctness, stream semantics, regression surface, privacy,
tests and docs), each required to prove findings by running code rather than
reading.
Five of the six independently found the same real defect, which I had missed:
the filter covered only two of the four patterns the cleaner strips, and only
in lowercase. Demonstrated leaks, each reproduced through the real agent and
client:
<MEMORY_COMMAND>-- raw JSON containing a proposed memory typed itself out, then vanished; the command was still parsed and executed<Code_Execution_Request>-- model-written source appeared, then disappeared<memo>-- legacy tag streamed verbatimThinking... ...done thinking.-- the model's entire private reasoning trace streamed into the answer bubbleThe same review found my drift-guard test was vacuous: it grepped
llm.pyforthe two tag names the filter already knew, so it passed by construction and
could never detect drift. It is replaced by a behavioural test that compares
what streams against what
_parse_and_clean_responseactually returns, acrossevery pattern and both chunkings. Reverted against the old filter, that test
fails 19 cases.
One latent issue was also closed:
streamedwas set before the publishervalidated the delta kind, so an engine emitting only an unrecognised kind would
have suppressed the replay having shown nothing.
Verification
Each load-bearing test was confirmed by reverting the behaviour it pins and
watching it fail: the no-double-replay test against a forced replay, the
early-arrival test against a narrowed trigger, and the filter's equivalence
tests against the pre-review table.
End-to-end proof through the real
SynthesisAgentandOllamaChatClient, withthe reply delivered in 7-character chunks: all four previously-leaking shapes
now stream exactly the cleaned answer, and a gated fake proves deltas arrive
before the model's final chunk.
Compatibility and rollback
Additive.
on_deltais optional at every seam and everyChatClientandGenerationEnginein the tree declares it; the generated API contract isunchanged (delta payloads ride an already-untyped field). No schema change, no
settings change, no migration. Rollback is a revert of the feature commit.
Known limits
Cancellation still discards the turn: deltas already shown are replaced when
the client reloads on
generation.cancelled, and persisting partial work is aseparate change in the API runner and the persistence path. With translation
enabled the original answer streams and the completed event carries the
translation -- deliberate, documented and tested, since live feedback beats a
spinner and translation is off by default. An unclosed block is withheld from
the stream but still appears in the finished answer, because the cleaner keeps
it; stripping it there is a separate fix.
🤖 Generated with Claude Code