Skip to content

Coalesce streamed reasoning into one client block - #54

Merged
maiphucgiang merged 2 commits into
mainfrom
fix/stream-reasoning-blocks
Oct 4, 2026
Merged

maiphucgiang merged 2 commits into
mainfrom
fix/stream-reasoning-blocks

Conversation

@maiphucgiang

@maiphucgiang maiphucgiang commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • coalesce streamed reasoning fragments into one client-visible reasoning event
  • preserve incremental visible text, refusal, and tool output after reasoning
  • bound the coalescing buffer with max_collect_bytes
  • add mixed reasoning/content regression coverage for Chat, Responses, and Messages
  • document the stream behavior in English and Chinese guides

Summary by Sourcery

Coalesce streamed reasoning before visible output so clients receive one continuous thinking section without losing incremental response content.

Bug Fixes:

  • Coalesce streamed reasoning fragments into a single client-visible event while preserving subsequent text, refusals, tool output, and completion signals.

Enhancements:

  • Apply bounded reasoning buffering through max_collect_bytes across Chat, Responses, and Messages streaming adapters.
  • Preserve multi-choice SSE envelopes and interleave non-data SSE controls without disrupting reasoning aggregation.

Documentation:

  • Document the updated realtime streaming behavior in the English and Chinese advanced guides.

Tests:

  • Add regression coverage for coalesced mixed reasoning and content streams across Chat, Responses, and Messages, including multi-choice SSE handling.

@sourcery-ai

sourcery-ai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR introduces bounded buffering around realtime Chat SSE streams so consecutive reasoning fragments are emitted as one client-visible reasoning block before incremental text, refusal, or tool output, with corresponding converter behavior, documentation updates, and cross-protocol regression tests.

Sequence diagram for coalescing streamed reasoning

sequenceDiagram
    participant Upstream as Upstream SSE
    participant Coalescer as _coalesce_reasoning_sse
    participant Client as Client

    Upstream->>Coalescer: reasoning_content fragment
    Upstream->>Coalescer: reasoning_content fragment
    Coalescer->>Coalescer: buffer and charge_text
    Upstream->>Coalescer: content, refusal, tool_calls, or finish_reason
    Coalescer->>Client: one reasoning_content delta
    Coalescer->>Client: visible output delta
    Upstream->>Coalescer: data [DONE]
    Coalescer->>Client: data [DONE]
Loading

Flow diagram for bounded reasoning buffering

flowchart LR
    A[Chat SSE stream] --> B[_coalesce_reasoning_sse]
    B --> C{Reasoning delta?}
    C -->|yes| D[Append to pending buffer]
    D --> E[StreamOutputBudget.charge_text]
    E --> F{Visible output or finish?}
    F -->|no| D
    F -->|yes| G[Flush one reasoning delta]
    C -->|no| G
    G --> H[Preserve incremental content refusal or tool output]
    H --> I[Client SSE stream]
    E --> J[max_collect_bytes bound]
Loading

File-Level Changes

Change Details Files
Coalesce streamed reasoning fragments into a single Chat-compatible delta while preserving subsequent incremental output.
  • Accumulate reasoning-only SSE deltas and emit them when visible content, refusal, tool output, completion, or stream boundaries occur.
  • Preserve the original event shape and role metadata, remove reasoning from mixed deltas, and retain framing for non-JSON and terminal lines.
  • Apply the configured max_collect_bytes budget to the coalescing buffer in both direct and adapted streaming paths.
  • Emit aggregated reasoning as one replayed SSE line for non-streaming conversion.
converter.py
Document the revised realtime streaming semantics and buffering behavior.
  • Clarify that reasoning is coalesced before visible output while text, refusal, and tool arguments remain incremental.
  • Update the corresponding Chinese documentation.
  • Retain the documented max_collect_bytes behavior and protocol-specific ordering details.
docs/advanced.md
docs/advanced.zh-CN.md
Add regression coverage for cross-protocol reasoning coalescing and ordering.
  • Test Chat, Responses, and Messages with multiple reasoning fragments followed by content.
  • Verify one combined reasoning block/event, preserved visible text, and reasoning-before-content ordering.
  • Assert non-streaming reasoning replay contains a single reasoning delta.
tests/test_realtime_streaming.py
tests/test_reasoning.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-04T07:55:28.741021Z 65aa35a New commits
🔒 Security Review ✅ Completed 2026-10-04T07:48:51.200749Z afe33af PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="converter.py" line_range="3198" />
<code_context>
+            if template is None:
+                template = event
+            pending.append(reasoning)
+            buffer_budget.charge_text(reasoning)
+            visible = any(delta.get(key) for key in ("content", "refusal", "tool_calls", "function_call"))
+            visible = visible or bool(choice.get("finish_reason"))
</code_context>
<issue_to_address>
**issue (broader_impact):** Reasoning bytes are charged once by the upstream `ChatSSEAccumulator` budget and charged again by `_coalesce_reasoning_sse` or the adapted converter, so a valid stream can exceed `max_collect_bytes` and fail with `response_too_large` even though its retained output is within the configured limit.

**Triggers:** When realtime streaming has a nonzero `max_collect_bytes` and the stream contains reasoning or other retained text.

**Suggested fix:** Share the existing request budget with `_coalesce_reasoning_sse`, or remove the duplicate charge from one of the buffering/conversion layers.
</issue_to_address>

### Comment 2
<location path="converter.py" line_range="3140-3146" />
<code_context>
+        source = template or {}
+        payload = dict(source)
+        choices = source.get("choices") or []
+        if choices:
+            choice = dict(choices[0])
+            delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
+            delta = {key: value for key, value in delta.items() if key == "role"}
+            delta["reasoning_content"] = "".join(pending)
+            choice["delta"] = delta
+            choice["finish_reason"] = None
+            payload["choices"] = [choice]
+        wire = "data: " + json.dumps(payload, ensure_ascii=False)
+        template = None
</code_context>
<issue_to_address>
**issue (bug_risk):** When an upstream SSE event contains multiple choices and the first choice has reasoning, the reconstructed payload replaces the original `choices` list with `[choice]`, permanently dropping every other choice from that event.

**Triggers:** When a request or upstream provider emits more than one choice in a reasoning-bearing SSE frame.

**Suggested fix:** Preserve and transform every choice in the original `choices` list, rather than reconstructing only `choices[0]`.

```suggestion
            transformed_choices = []
            for choice in choices:
                choice = dict(choice)
                delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
                delta = {key: value for key, value in delta.items() if key == "role"}
                delta["reasoning_content"] = "".join(pending)
                choice["delta"] = delta
                choice["finish_reason"] = None
                transformed_choices.append(choice)
            payload["choices"] = transformed_choices
```
</issue_to_address>

### Comment 3
<location path="converter.py" line_range="3127" />
<code_context>
     return sanitize_log_text(f"{type(error).__name__}: {str(error).strip() or 'upstream transport failed'}", 512)

+async def _coalesce_reasoning_sse(lines, *, max_bytes=0):
+    """Emit one Chat reasoning delta before visible output for each stream."""
+    pending: list[str] = []
+    template = None
</code_context>
<issue_to_address>
**nitpick:** The function docstring says it emits one Chat reasoning delta, but the function is also inserted into the Responses and Anthropic adapted streaming paths, so its documented scope does not describe its actual callers or behavior.

**Suggested fix:** Update the docstring to describe coalescing normalized Chat SSE reasoning before all downstream protocol adapters.

```suggestion
    """Coalesce normalized Chat SSE reasoning before all downstream protocol adapters."""
```
</issue_to_address>

Sourcery assessment

Approval pending. 2 findings to address first.

Blocking findings: converter.py:3198, converter.py:3146


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread converter.py
if template is None:
template = event
pending.append(reasoning)
buffer_budget.charge_text(reasoning)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): Reasoning bytes are charged once by the upstream ChatSSEAccumulator budget and charged again by _coalesce_reasoning_sse or the adapted converter, so a valid stream can exceed max_collect_bytes and fail with response_too_large even though its retained output is within the configured limit.

Triggers: When realtime streaming has a nonzero max_collect_bytes and the stream contains reasoning or other retained text.

Suggested fix: Share the existing request budget with _coalesce_reasoning_sse, or remove the duplicate charge from one of the buffering/conversion layers.

Comment thread converter.py Outdated
Comment thread converter.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afe33af1fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread converter.py
Comment on lines +3160 to +3165
if not line.startswith("data:"):
flushed = flush()
if flushed is not None:
yield flushed
yield ""
yield line

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep coalescing across SSE control lines

When the upstream stream contains a valid SSE comment or control line (for example a keep-alive : ping or event: line) between two reasoning deltas, this branch flushes and resets the pending buffer before forwarding that line. The following reasoning then becomes a separate client block, so Responses/Anthropic clients can observe multiple thinking sections despite the new coalescing guarantee; ignorable SSE control lines should not terminate the reasoning run.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65aa35a5bf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread converter.py
Comment on lines +3202 to +3205
visible = any(delta.get(key) for key in ("content", "refusal", "tool_calls", "function_call"))
visible = visible or bool(choice.get("finish_reason"))
if not visible:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve visible deltas from other choices

When an upstream chunk contains reasoning in choice 0 but visible content, refusal, or tool data in another choice, visible is computed only from choice 0 and this branch continues, dropping the entire event. The later transformed event cannot recover that other choice's delta, so multi-choice responses silently lose output; determine visibility across all choices before suppressing the frame.

Useful? React with 👍 / 👎.

@maiphucgiang
maiphucgiang merged commit f3e30e0 into main Oct 4, 2026
9 checks passed
@maiphucgiang
maiphucgiang deleted the fix/stream-reasoning-blocks branch October 4, 2026 08:38
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.

1 participant