fix(agent): de-glue summary-part boundaries in the live reasoning display

Routing the live display through reasoning_details bypassed
separate_glued_reasoning_blocks, so a reasoning-summary model's
head-to-tail bold headings glued into '**One****Two**' in the live box
while the persisted reasoning_content stayed repaired. De-glue the
detail-derived text against its own display accumulator (not
reasoning_parts — mirrored fields would insert a spurious break on the
first chunk), so display and history agree.
This commit is contained in:
Brooklyn Nicholson
2026-09-25 18:12:17 -05:00
committed by brooklyn!
parent 7e55f5dee1
commit 9b637e37ff
2 changed files with 62 additions and 1 deletions

View File

@@ -3148,6 +3148,11 @@ class _StreamingCall(StreamingWaitMonitor):
base_timeout, read_timeout, conn_cap = self._stream_timeouts() base_timeout, read_timeout, conn_cap = self._stream_timeouts()
content_parts: list = [] content_parts: list = []
reasoning_parts: list = [] reasoning_parts: list = []
# Live-display accumulator for detail-derived reasoning text: de-gluing must
# compare against what the display actually received, not ``reasoning_parts``
# (a provider that mirrors the same text in both fields would otherwise read
# as already-glued on the first chunk and get a spurious break).
detail_display_parts: list[str] = []
# OpenAI structured refusal (``delta.refusal``): the explanation streams here and # OpenAI structured refusal (``delta.refusal``): the explanation streams here and
# ``delta.content`` stays empty, so an un-accumulated refusal looks like an empty # ``delta.content`` stays empty, so an un-accumulated refusal looks like an empty
# stream and burns the empty-response retries (the non-streaming fix is #46013). # stream and burns the empty-response retries (the non-streaming fix is #46013).
@@ -3247,7 +3252,17 @@ class _StreamingCall(StreamingWaitMonitor):
# Details may carry the full text while ordinary reasoning is only # Details may carry the full text while ordinary reasoning is only
# a sparse fragment or a mirror. Deliver one representation per # a sparse fragment or a mirror. Deliver one representation per
# chunk, without rewriting either persisted/replayed field. # chunk, without rewriting either persisted/replayed field.
display_reasoning = "".join(detail_text_parts) or reasoning_text # Summary-part boundaries need the same repair the plain path applies:
# de-glue against the detail display's own accumulator (not
# ``reasoning_parts`` — with mirrored fields that would insert a
# spurious break on the first chunk), so the live box and the
# persisted ``reasoning_content`` agree.
detail_text = "".join(detail_text_parts)
if detail_text:
detail_text = separate_glued_reasoning_blocks(
detail_display_parts[-1] if detail_display_parts else "", detail_text)
detail_display_parts.append(detail_text)
display_reasoning = detail_text or reasoning_text
if display_reasoning: if display_reasoning:
self._emit_reasoning(display_reasoning) self._emit_reasoning(display_reasoning)
# Not routed to the live display: the transport promotes a sole-payload # Not routed to the live display: the transport promotes a sole-payload

View File

@@ -78,6 +78,52 @@ def test_streamed_details_land_on_final_message_and_persist(_mock_close, mock_cr
assert persisted["reasoning_details"] == msg.reasoning_details assert persisted["reasoning_details"] == msg.reasoning_details
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_live_details_deglue_summary_part_boundaries(_mock_close, mock_create):
"""The live display gets the same boundary repair as the persisted reasoning.
A reasoning-summary model streams one delta per completed summary part, each
a bare bold heading. Without the ``separate_glued_reasoning_blocks`` repair
on the detail path, the live box glues parts head-to-tail while
``reasoning_content`` stays de-glued — display and history disagree.
"""
agent = _agent()
client = MagicMock()
mock_create.return_value = client
def summary_chunk(summary, **kw):
chunk = _make_chunk(**kw)
chunk.choices[0].delta.reasoning = summary # provider mirrors both fields
chunk.choices[0].delta.model_extra = {
"reasoning_details": [{"type": "reasoning.summary", "summary": summary}]}
return chunk
client.chat.completions.create.return_value = iter([
summary_chunk("**One**"),
summary_chunk("**Two**"),
_make_chunk(content="Answer", finish_reason="stop", model="test-model"),
])
delivered = []
agent.reasoning_callback = delivered.append
response = agent._interruptible_streaming_api_call({})
assert "".join(delivered) == "**One**\n\n**Two**"
assert response.choices[0].message.reasoning_content == "**One**\n\n**Two**"
# Details-only variant: the delta carries no plain ``reasoning`` field at all.
client.chat.completions.create.return_value = iter([
_make_chunk(reasoning_details=[{"type": "reasoning.summary", "summary": "**One**"}]),
_make_chunk(reasoning_details=[{"type": "reasoning.summary", "summary": "**Two**"}]),
_make_chunk(content="Answer", finish_reason="stop", model="test-model"),
])
delivered = []
agent.reasoning_callback = delivered.append
response = agent._interruptible_streaming_api_call({})
assert "".join(delivered) == "**One**\n\n**Two**"
@patch("run_agent.AIAgent._create_request_openai_client") @patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client") @patch("run_agent.AIAgent._close_request_openai_client")
def test_no_details_leaves_attribute_absent(_mock_close, mock_create): def test_no_details_leaves_attribute_absent(_mock_close, mock_create):