From 0f836618081c289546e81d0a1e117f01bb8d75f2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 6 Aug 2026 22:02:37 -0500 Subject: [PATCH 1/3] fix(reasoning): keep gpt-5.x summary parts as separate blocks on the chat wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning-summary models emit one reasoning_content delta per completed summary part, each a self-contained bold heading. The Responses API delimits those parts with summary_index; the OpenAI chat wire carries no such field — verified live against Nous Portal, whose reasoning chunks contain nothing but delta.reasoning_content — so concatenating them glued every part into one unspaced, half-bold paragraph. Re-derive the boundary from the signal the wire does carry: a delta opening a closed bold heading against a mid-line tail. This matches Hermes own Responses adapter, which already joins its summary parts with a blank line. --- agent/chat_completion_helpers.py | 9 +++ agent/reasoning_summaries.py | 67 +++++++++++++++++++++++ tests/agent/test_reasoning_summaries.py | 73 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 agent/reasoning_summaries.py create mode 100644 tests/agent/test_reasoning_summaries.py diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 0fd556f8403d0..11a8c86535e98 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -39,6 +39,7 @@ from agent.message_sanitization import ( _sanitize_surrogates, _repair_tool_call_arguments, ) +from agent.reasoning_summaries import separate_glued_reasoning_blocks from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current from tools.terminal_tool import is_persistent_env from utils import base_url_host_matches, base_url_hostname, env_float, env_int @@ -3256,6 +3257,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # Accumulate reasoning content reasoning_text = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) if reasoning_text: + # Summary-part models (gpt-5.x and other Responses relays) send + # one complete markdown block per delta with no separator, so + # the parts glue into a single unreadable run. Only the tail of + # what's accumulated matters. See agent/reasoning_summaries.py. + reasoning_text = separate_glued_reasoning_blocks( + reasoning_parts[-1] if reasoning_parts else "", + reasoning_text, + ) reasoning_parts.append(reasoning_text) _fire_first_delta() agent._fire_reasoning_delta(reasoning_text) diff --git a/agent/reasoning_summaries.py b/agent/reasoning_summaries.py new file mode 100644 index 0000000000000..2dd1144fce992 --- /dev/null +++ b/agent/reasoning_summaries.py @@ -0,0 +1,67 @@ +"""Boundary repair for providers that stream reasoning as discrete summary parts. + +Reasoning-summary models (OpenAI's gpt-5.x family, and anything relaying the +Responses API onto the OpenAI chat wire) do not stream a chain of thought token +by token. They emit one ``reasoning_content`` delta per *completed* summary +part, each opening with a bold markdown heading:: + + {"delta": {"reasoning_content": "**Investigating likely culprit PRs**"}} + {"delta": {"reasoning_content": "**Inspecting message schema**"}} + +On the Responses API those parts are delimited by ``summary_index`` +(``response.reasoning_summary_part.added`` / ``.done``). The OpenAI chat wire +carries no such field — verified live against Nous Portal's +``openai/gpt-5.6-sol``, whose reasoning chunks contain nothing but +``delta.reasoning_content`` — so the boundary cannot be recovered from +metadata, and consumers that concatenate deltas glue the parts together: + + **Investigating likely culprit PRs****Inspecting message schema** + +That ``****`` run is neither a bold close nor a bold open to a markdown parser, +so the whole trace renders as one unbroken, unspaced, half-bold paragraph. + +The AI SDK hit exactly this (vercel/ai#6742) and fixed it upstream by starting +a new reasoning part per ``summary_index``. That route needs the index, which +this wire does not give us, so we re-derive the boundary from the one signal it +does carry: a delta opening a bold heading. Hermes' own Responses adapter +already joins its summary parts with a blank line +(``agent/codex_responses_adapter.py``), so this brings the chat-completions +stream in line with the path that keeps the structure. +""" + +from __future__ import annotations + +__all__ = ["separate_glued_reasoning_blocks"] + + +def separate_glued_reasoning_blocks(previous: str, delta: str) -> str: + """Return *delta*, prefixed with a paragraph break when it glues onto *previous*. + + *previous* is the reasoning text accumulated so far (only its tail matters). + A break is inserted when *delta* opens a bold heading and *previous* is + mid-line, which is the summary-part boundary the chat wire drops. Both + shapes the upstream issue reports are covered: a heading-only part butting + against the next heading (``**One****Two**``), and a part whose prose body + butts against the next heading (``...interaction!**Next**``). + + Token-streamed reasoning is left alone: its deltas carry their own leading + whitespace, so *previous* ends mid-line only when the model really did run + two parts together. + """ + if not previous or not delta: + return delta + + if not delta.startswith("**"): + return delta + + # Already separated — the provider (or an earlier part) ended the line. + if previous[-1].isspace(): + return delta + + # Require a *closed* heading. A token-streamed fragment that merely opens + # emphasis ("**" then "bold" then "**" across three deltas) is not a part + # boundary; a summary part always carries its whole heading in one delta. + if "**" not in delta[2:]: + return delta + + return f"\n\n{delta}" diff --git a/tests/agent/test_reasoning_summaries.py b/tests/agent/test_reasoning_summaries.py new file mode 100644 index 0000000000000..6c93c534e3b75 --- /dev/null +++ b/tests/agent/test_reasoning_summaries.py @@ -0,0 +1,73 @@ +"""Reasoning summary-part boundary repair (agent/reasoning_summaries.py).""" + +from agent.reasoning_summaries import separate_glued_reasoning_blocks + + +def _stream(deltas): + """Accumulate *deltas* the way the chat-completions stream loop does.""" + parts: list[str] = [] + for delta in deltas: + parts.append( + separate_glued_reasoning_blocks(parts[-1] if parts else "", delta) + ) + return "".join(parts) + + +def test_heading_only_parts_do_not_glue_into_one_run(): + # The shape observed live on Nous Portal's openai/gpt-5.6-sol: each delta + # is a bare heading, so consecutive parts produce a `****` run. + text = _stream( + [ + "**Investigating likely culprit PRs**", + "**Inspecting message schema and tool_calls content**", + "**Analyzing interrupted tool call impact**", + ] + ) + + assert "****" not in text + assert text.splitlines() == [ + "**Investigating likely culprit PRs**", + "", + "**Inspecting message schema and tool_calls content**", + "", + "**Analyzing interrupted tool call impact**", + ] + + +def test_prose_body_does_not_glue_onto_the_next_heading(): + # vercel/ai#6742's repro: a part ends in prose and the next heading butts + # straight onto it, with no `****` run to key off. + text = _stream( + [ + "**Simulating a greeting stream**\n\nIt feels like a streaming interaction!", + "**Simulating a greeting stream**\n\nI want to meet the request.", + ] + ) + + assert "interaction!**" not in text + assert "interaction!\n\n**Simulating" in text + + +def test_token_streamed_reasoning_is_untouched(): + deltas = ["Looking at", " the session", " logs, I see", " one bold word."] + + assert _stream(deltas) == "".join(deltas) + + +def test_bold_word_mid_sentence_is_not_a_boundary(): + # Emphasis inside token-streamed prose arrives after a space. + assert separate_glued_reasoning_blocks("I see the ", "**signature**") == "**signature**" + + +def test_unclosed_emphasis_fragment_is_not_a_boundary(): + # A token stream splitting emphasis across deltas opens but never closes. + assert separate_glued_reasoning_blocks("weighing", "**") == "**" + + +def test_boundary_needs_a_bold_opener(): + assert separate_glued_reasoning_blocks("**Closing**", "plain head") == "plain head" + + +def test_empty_operands_pass_through(): + assert separate_glued_reasoning_blocks("", "**first**") == "**first**" + assert separate_glued_reasoning_blocks("**first**", "") == "" From 6bb630ef783e8bacdb4135fd06b6fcd2445eeb51 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 6 Aug 2026 22:02:46 -0500 Subject: [PATCH 2/3] fix(codex): split reasoning summary parts on summary_index The native Responses stream does carry summary_index, so the part boundary is structured data here rather than something to infer. Break on a change of index, and leave streams that send no index (plain reasoning_text) untouched. --- agent/codex_runtime.py | 15 ++++++ .../test_run_agent_codex_responses.py | 54 +++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index c01084c4a4499..f36f1c78fedac 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1027,6 +1027,10 @@ def _consume_codex_event_stream( first_delta_fired = False active_message_phase: str | None = None commentary_text_deltas: List[str] = [] + # Last reasoning summary_index seen. The Responses stream delimits summary + # parts by this index and gives each part no separator of its own, so a + # change of index is where the blank line belongs. + active_summary_index: Any = None terminal_status: str = "completed" terminal_usage: Any = None terminal_response_id: str = None @@ -1120,6 +1124,17 @@ def _consume_codex_event_stream( if "reasoning" in event_type and "delta" in event_type: reasoning_text = _event_field(event, "delta", "") if reasoning_text and on_reasoning_delta is not None: + # Summary parts stream one after another with no separator of + # their own; summary_index is the boundary the wire gives us. + summary_index = _event_field(event, "summary_index") + if ( + summary_index is not None + and active_summary_index is not None + and summary_index != active_summary_index + ): + reasoning_text = f"\n\n{reasoning_text}" + if summary_index is not None: + active_summary_index = summary_index try: on_reasoning_delta(reasoning_text) except Exception: diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 4b11b1fdb7db9..18dc13526b7dc 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -1985,11 +1985,59 @@ def test_duplicate_detection_uses_commentary_when_hidden_reasoning_changes(monke +def test_consume_codex_stream_separates_reasoning_summary_parts(): + """summary_index is the part boundary; the wire sends no separator itself.""" + from agent.codex_runtime import _consume_codex_event_stream + + reasoning_streamed = [] + + _consume_codex_event_stream( + _FakeCreateStream([ + SimpleNamespace(type="response.created"), + SimpleNamespace( + type="response.reasoning_summary_text.delta", + summary_index=0, + delta="**Investigating culprit PRs**", + ), + SimpleNamespace( + type="response.reasoning_summary_text.delta", + summary_index=1, + delta="**Inspecting message schema**", + ), + SimpleNamespace( + type="response.reasoning_summary_text.delta", + summary_index=1, + delta=" and tool_calls content", + ), + SimpleNamespace(type="response.completed", response=SimpleNamespace(status="completed")), + ]), + model="gpt-5-codex", + on_reasoning_delta=reasoning_streamed.append, + ) + + joined = "".join(reasoning_streamed) + assert "****" not in joined + assert joined == ( + "**Investigating culprit PRs**" + "\n\n**Inspecting message schema** and tool_calls content" + ) +def test_consume_codex_stream_leaves_unindexed_reasoning_untouched(): + """Streams with no summary_index (plain reasoning_text) must not gain breaks.""" + from agent.codex_runtime import _consume_codex_event_stream + reasoning_streamed = [] + _consume_codex_event_stream( + _FakeCreateStream([ + SimpleNamespace(type="response.created"), + SimpleNamespace(type="response.reasoning_text.delta", delta="Need to "), + SimpleNamespace(type="response.reasoning_text.delta", delta="inspect files."), + SimpleNamespace(type="response.completed", response=SimpleNamespace(status="completed")), + ]), + model="gpt-5-codex", + on_reasoning_delta=reasoning_streamed.append, + ) - - - + assert "".join(reasoning_streamed) == "Need to inspect files." From a5cddcd8dce6d29f8e51221f265ad0805bbf2479 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 6 Aug 2026 22:02:46 -0500 Subject: [PATCH 3/3] fix(desktop): render already-glued reasoning as separate blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repairs what is already in the transcript: reasoning persisted before the backend fix, and any provider still gluing its parts. Handles both shapes — heading-onto-heading (the **** run) and prose-onto-heading (vercel/ai#6742). Verified against 46 real glued messages from a gpt-5.6-sol session; all repair cleanly and idempotently. --- .../assistant-ui/thread/message-parts.tsx | 3 +- apps/desktop/src/lib/reasoning-blocks.test.ts | 48 +++++++++++++++++++ apps/desktop/src/lib/reasoning-blocks.ts | 31 ++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/reasoning-blocks.test.ts create mode 100644 apps/desktop/src/lib/reasoning-blocks.ts diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 8bcc946453871..df42bc175423c 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -16,6 +16,7 @@ import { GeneratedImage } from '@/components/chat/generated-image-result' import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' import { useI18n } from '@/i18n' import { generatedImageFromResult } from '@/lib/generated-images' +import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -249,7 +250,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => { containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>} disableArtifacts isRunning={status.type === 'running' || messageRunning} - text={text.trimStart()} + text={separateGluedReasoningBlocks(text.trimStart())} /> ) } diff --git a/apps/desktop/src/lib/reasoning-blocks.test.ts b/apps/desktop/src/lib/reasoning-blocks.test.ts new file mode 100644 index 0000000000000..cab4ab90e25b9 --- /dev/null +++ b/apps/desktop/src/lib/reasoning-blocks.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks' + +describe('separateGluedReasoningBlocks', () => { + it('splits heading-onto-heading parts (the `****` run)', () => { + const glued = + '**Investigating likely culprit PRs****Inspecting message schema****Analyzing interrupted tool call impact**' + + expect(separateGluedReasoningBlocks(glued)).toBe( + [ + '**Investigating likely culprit PRs**', + '', + '**Inspecting message schema**', + '', + '**Analyzing interrupted tool call impact**' + ].join('\n') + ) + }) + + it('splits prose-onto-heading parts (vercel/ai#6742 repro)', () => { + const glued = + '**Simulating a greeting stream**\n\nIt feels like a streaming interaction!**Simulating a greeting stream**\n\nI want to meet the request.' + + expect(separateGluedReasoningBlocks(glued)).toContain('interaction!\n\n**Simulating') + expect(separateGluedReasoningBlocks(glued)).not.toContain('interaction!**') + }) + + it('is idempotent on already-separated text', () => { + const separated = '**One**\n\n**Two**' + + expect(separateGluedReasoningBlocks(separated)).toBe(separated) + }) + + it('leaves emphasis inside prose alone', () => { + const prose = 'Looking at the logs, the **signature** field is missing — so the replay 400s.' + + expect(separateGluedReasoningBlocks(prose)).toBe(prose) + }) + + it('leaves an unclosed emphasis run alone', () => { + expect(separateGluedReasoningBlocks('weighing options **')).toBe('weighing options **') + }) + + it('does not split a heading that already opens the text', () => { + expect(separateGluedReasoningBlocks('**Only one part**')).toBe('**Only one part**') + }) +}) diff --git a/apps/desktop/src/lib/reasoning-blocks.ts b/apps/desktop/src/lib/reasoning-blocks.ts new file mode 100644 index 0000000000000..a1122bd7478b2 --- /dev/null +++ b/apps/desktop/src/lib/reasoning-blocks.ts @@ -0,0 +1,31 @@ +/** + * Reasoning-summary models (OpenAI's gpt-5.x family, and anything relaying the + * Responses API onto the OpenAI chat wire) emit one delta per *completed* + * summary part, each opening with a bold markdown heading: + * + * **Investigating likely culprit PRs** + * **Inspecting message schema** + * + * The Responses API delimits those parts with `summary_index`; the chat wire + * carries no such field, so concatenated deltas glue into + * `...PRs****Inspecting...` — a `****` run markdown reads as neither a bold + * close nor a bold open, leaving one unbroken, unspaced, half-bold paragraph. + * The AI SDK hit the same bug (vercel/ai#6742). + * + * The backend now inserts the break as the deltas arrive. This repairs the text + * we display: reasoning persisted before that fix, and any provider still + * gluing its parts. Idempotent — a break already present is left alone. + */ + +// A heading butting straight onto the previous part, in the two shapes the +// wire produces: +// 1. heading-onto-heading — `**One****Two**`, a bare `****` run. +// 2. prose-onto-heading — `interaction!**Two**`. +// Emphasis that legitimately follows whitespace is left alone, and a heading +// must close on its own line to count as a summary part. +const GLUED_HEADING_RUN = /(?