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/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/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/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 = /(?