fix(response): coalesce tiny reasoning deltas into small blocks - #56
Open
jatmn wants to merge 5 commits into
Open
fix(response): coalesce tiny reasoning deltas into small blocks#56jatmn wants to merge 5 commits into
jatmn wants to merge 5 commits into
Conversation
Kimi (and other providers that stream reasoning via chat completions) was sending one line or one token at a time. codex-warp forwarded each fragment as its own response.reasoning_summary_text.delta event, so Codex clients showed thinking one line at a time instead of the small blocks seen with GPT models. Buffer streaming reasoning in both conversion paths: - Chat completions: ChatAccum now buffers reasoning in reasoning_pending and emits a delta only when the buffer reaches ~160 chars or hits a paragraph boundary (\n\n). Any remaining text is flushed at finish(). - Native Responses: NativeReasoningBuffer coalesces response.reasoning_summary_text.delta events using the same threshold, and flushes before any non-reasoning event or stream termination. Validation: - PATH=.../stable-x86_64-unknown-linux-gnu/bin:$PATH cargo test --locked response_codec::tests - PATH=.../stable-x86_64-unknown-linux-gnu/bin:$PATH SOURCE_CHECKS_SKIP_TYPOS=1 bash scripts/source-checks.sh - PATH=.../stable-x86_64-unknown-linux-gnu/bin:$PATH cargo build --locked Note: the full cargo test --locked suite has pre-existing sandbox-only failures from tests that bind local listeners (PermissionDenied). The response_codec tests all pass.
The Incremental cargo-mutants CI job found two surviving mutants in NativeReasoningBuffer::append where the || operators could be replaced with && without a test failing. This happened because the existing tests never changed the item_id/summary_index of a native reasoning stream, so the buffer reset logic was unverified. Add native_stream_reasoning_summary_deltas_reset_on_different_item_id, which also exposed that reset did not flush the previously buffered content before starting a new buffer. Fixed NativeReasoningBuffer::append to return any flushed event when the reasoning identity changes, then starts the new buffer with the incoming delta. Validation: - PATH=.../stable-x86_64-unknown-linux-gnu/bin:$PATH cargo test --locked response_codec::tests → 188 passed - PATH=.../stable-x86_64-unknown-linux-gnu/bin:$PATH SOURCE_CHECKS_SKIP_TYPOS=1 bash scripts/source-checks.sh → ok - PATH=.../stable-x86_64-unknown-linux-gnu/bin:$PATH cargo build --locked → ok
jatmn
marked this pull request as ready for review
August 19, 2026 01:06
Owner
Author
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- native_reasoning_summary_delta() uses expect() on the parsed JSON object, which will panic on malformed or unexpected upstream frames; consider handling this more defensively so a bad provider event doesn’t take down the stream.
- NativeReasoningBuffer::append() and take_flush() currently operate only on item_id and summary_index; if other identifying fields are added to reasoning events in the future, the buffer may coalesce incompatible deltas, so it may be worth centralising the identity check in a helper or documenting the assumptions more explicitly.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- native_reasoning_summary_delta() uses expect() on the parsed JSON object, which will panic on malformed or unexpected upstream frames; consider handling this more defensively so a bad provider event doesn’t take down the stream.
- NativeReasoningBuffer::append() and take_flush() currently operate only on item_id and summary_index; if other identifying fields are added to reasoning events in the future, the buffer may coalesce incompatible deltas, so it may be worth centralising the identity check in a helper or documenting the assumptions more explicitly.
## Individual Comments
### Comment 1
<location path="src/response_codec.rs" line_range="76-85" />
<code_context>
+ if should_flush { self.take_all() } else { None }
+ }
+
+ fn take_all(&mut self) -> Option<String> {
+ if self.pending.is_empty() {
+ return None;
+ }
+ let mut value = self.template.clone()?;
+ if let Some(delta) = value.get_mut("delta") {
+ *delta = Value::String(self.pending.clone());
+ }
+ self.pending.clear();
+ Some(sse("response.reasoning_summary_text.delta", value))
+ }
+}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Retaining `template` after `take_all` can mis-detect identity changes on the next stream.
After `take_all`, `pending` is cleared but `template`, `item_id`, and `summary_index` remain set. On the next reasoning event, `append` will see `template.is_some()` and perform identity comparison against stale metadata, potentially triggering `identity_changed` and a flush even though `pending` is empty. Consider clearing `template` (and `item_id`/`summary_index` if applicable) in `take_all` so identity detection is based only on actual buffered content.
Suggested implementation:
```rust
let text_only = self
.pending
.strip_prefix(REASONING_DISPLAY_HEADER)
.unwrap_or(&self.pending);
let should_flush = !text_only.is_empty()
&& (text_only.len() >= REASONING_DELTA_FLUSH_CHARS
|| text_only.contains(REASONING_DELTA_FLUSH_PARAGRAPH));
if should_flush { self.take_all() } else { None }
}
fn take_all(&mut self) -> Option<String> {
// When we finish a buffered stream, clear identity metadata so
// future identity comparisons don't use stale template/item_id/summary_index.
if self.pending.is_empty() {
self.template = None;
self.item_id = None;
self.summary_index = None;
return None;
}
// Take ownership of the template (and clear it) so it can't be reused
// for subsequent streams.
let mut value = self.template.take()?;
if let Some(delta) = value.get_mut("delta") {
*delta = Value::String(self.pending.clone());
}
// Clear all buffered state for the completed stream.
self.pending.clear();
self.item_id = None;
self.summary_index = None;
Some(sse("response.reasoning_summary_text.delta", value))
}
}
impl NativeReasoningBuffer {
```
```rust
fn append(&mut self, delta: &NativeReasoningSummaryDelta) -> Option<String> {
let identity_changed = self.template.is_some()
&& (self.item_id.as_deref() != Some(delta.item_id.as_str())
|| self.summary_index != Some(delta.summary_index));
let flushed = if identity_changed {
// take_all now also clears template/item_id/summary_index so
// identity detection for the next stream is based solely on new content.
self.take_all()
} else {
None
```
If `item_id` or `summary_index` are not `Option` types, adjust the `None` assignments to the appropriate "cleared" value for your types (e.g. `String::new()` or a sentinel index). The core idea is that `take_all` must reset all identity-related fields (template, item_id, summary_index) whenever it completes or decides there's nothing to flush, so subsequent calls to `append` don't compare against stale metadata.
</issue_to_address>
### Comment 2
<location path="src/response_codec_tests.rs" line_range="311" />
<code_context>
+#[tokio::test]
+async fn native_stream_reasoning_summary_deltas_reset_on_different_item_id() {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a companion native-stream test that verifies buffer reset on summary_index changes.
The current test only covers buffer reset when `item_id` changes. Please add a complementary test (e.g., `native_stream_reasoning_summary_deltas_reset_on_different_summary_index`) where `item_id` is constant but `summary_index` changes, and assert that you get two separate deltas (one per index) instead of a single coalesced block. This will protect the identity-based reset logic from regressions along the `summary_index` path.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Address Sourcery review findings on the native reasoning buffer and fix the
live invalid_prompt failure seen with Kimi history replay.
Native reasoning buffer:
- Clear template/item_id/summary_index in take_all so later streams do not
compare against stale identity after a flush.
- Parse reasoning templates with as_object_mut()? instead of expect().
- Document that identity is the (item_id, summary_index) pair.
- Add summary_index reset and post-flush identity regression tests.
Chat history tool calls:
- Session history can contain truncated function_call.arguments such as
`{"cmd": "gh`. Replaying those into Chat Completions causes providers to
reject the whole request with:
messages.N.tool_calls.0.function.arguments must be a valid JSON object string
- Normalize history arguments to a JSON object string on the outbound
responses->chat path (invalid/truncated -> `{}`, non-object JSON wrapped).
Validation:
- cargo test --locked response_codec::tests
- cargo test --locked transform::tests
- SOURCE_CHECKS_SKIP_TYPOS=1 bash scripts/source-checks.sh
- cargo build --locked
Owner
Author
Review follow-up (c2756ed)Addressed the Sourcery threads and one additional live failure found in local warp/session logs. Sourcery
Additional live bug (not previously in PR scope)
Validation: focused response_codec + transform tests green; source-checks/clippy green. |
Owner
Author
|
@sourcery-ai review |
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.
Problem
When using models like kimi-k2.7-code that stream reasoning via the OpenAI chat-completions shape, codex-warp was forwarding every tiny provider fragment as its own response.reasoning_summary_text.delta SSE event. Codex clients then rendered the thinking one line/token at a time, instead of the small blocks normally seen with GPT-style Responses API streams.
Root cause
ChatAccum::apply_chat_chunk emitted a reasoning delta event immediately for every upstream chunk. There was no buffering or coalescing step, so the granularity of the provider's chunks directly determined the granularity of the UI update.
Fix
Buffer streaming reasoning in both response conversion paths and emit it in small blocks:
Tests
Validation
Limitations / notes
Fixes the streaming thinking display issue for kimi-k2.7-code and other providers that emit tiny reasoning deltas.
Summary by Sourcery
Coalesce streamed reasoning into client-friendly blocks and make replayed tool-call history compatible with strict Chat Completions providers.
Bug Fixes:
Enhancements:
Tests: