3535 convert_codex_to_agentex_events ,
3636)
3737from agentex .types .reasoning_content_delta import ReasoningContentDelta
38+ from agentex .types .reasoning_summary_delta import ReasoningSummaryDelta
3839
3940
4041async def _aiter (items : list [Any ]) -> AsyncIterator [Any ]:
@@ -365,7 +366,15 @@ async def test_tool_indices_request_before_response(self) -> None:
365366
366367
367368class TestReasoningStreaming :
368- async def test_reasoning_start_full (self ) -> None :
369+ async def test_reasoning_start_deltas_done (self ) -> None :
370+ """A reasoning block opens with a Start, streams the final text as
371+ summary + content deltas, and closes with a Done.
372+
373+ It must NOT emit a Full at the open Start's index: auto_send routes a
374+ Full into a throwaway streaming context (ignoring the index), which
375+ would leave the Start context dangling and persist a duplicate, empty
376+ reasoning message (AGX1 codex reasoning duplicate bug).
377+ """
369378 events = [
370379 {"type" : "item.started" , "item" : {"id" : "r1" , "type" : "reasoning" , "text" : "" }},
371380 {
@@ -380,44 +389,64 @@ async def test_reasoning_start_full(self) -> None:
380389 out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
381390
382391 starts = [e for e in out if isinstance (e , StreamTaskMessageStart )]
383- fulls = [e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ReasoningContent )]
392+ dones = [e for e in out if isinstance (e , StreamTaskMessageDone )]
393+ reasoning_fulls = [
394+ e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ReasoningContent )
395+ ]
396+ content_deltas = [
397+ e for e in out if isinstance (e , StreamTaskMessageDelta ) and isinstance (e .delta , ReasoningContentDelta )
398+ ]
399+ summary_deltas = [
400+ e for e in out if isinstance (e , StreamTaskMessageDelta ) and isinstance (e .delta , ReasoningSummaryDelta )
401+ ]
384402
403+ # Exactly one message: Start + deltas + Done, all on the same index, no Full.
385404 assert len (starts ) == 1
386405 assert isinstance (starts [0 ].content , ReasoningContent )
387- assert len (fulls ) == 1
388- assert isinstance (fulls [0 ].content , ReasoningContent )
389- reasoning_content = fulls [0 ].content .content
390- assert reasoning_content is not None
391- assert any ("thinking... done" in s for s in reasoning_content )
392-
393- async def test_reasoning_initial_text_emits_delta (self ) -> None :
394- events = [
395- {
396- "type" : "item.started" ,
397- "item" : {"id" : "r1" , "type" : "reasoning" , "text" : "seed" },
398- },
399- ]
400- out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
401- deltas = [e for e in out if isinstance (e , StreamTaskMessageDelta )]
402- assert len (deltas ) == 1
403- assert isinstance (deltas [0 ].delta , ReasoningContentDelta )
404- assert deltas [0 ].delta .content_delta == "seed"
405-
406- async def test_reasoning_no_started_emits_standalone_full (self ) -> None :
407- """If item.completed arrives without item.started, emit a standalone Full."""
406+ assert reasoning_fulls == []
407+ assert len (content_deltas ) == 1
408+ content_delta = content_deltas [0 ].delta
409+ assert isinstance (content_delta , ReasoningContentDelta )
410+ assert content_delta .content_delta == "thinking... done"
411+ assert len (summary_deltas ) == 1
412+ summary_delta = summary_deltas [0 ].delta
413+ assert isinstance (summary_delta , ReasoningSummaryDelta )
414+ assert summary_delta .summary_delta == "thinking... done"
415+ assert len (dones ) == 1
416+ idx = starts [0 ].index
417+ assert content_deltas [0 ].index == idx
418+ assert summary_deltas [0 ].index == idx
419+ assert dones [0 ].index == idx
420+
421+ async def test_reasoning_no_started_opens_and_closes_one_message (self ) -> None :
422+ """If item.completed arrives without item.started, the converter opens a
423+ Start lazily and closes it with a Done (still one clean message, no Full)."""
408424 events = [
409425 {
410426 "type" : "item.completed" ,
411427 "item" : {"id" : "r_orphan" , "type" : "reasoning" , "text" : "orphan thought" },
412428 }
413429 ]
414430 out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
415- fulls = [e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ReasoningContent )]
416- assert len (fulls ) == 1
417- assert isinstance (fulls [0 ].content , ReasoningContent )
418- orphan_content = fulls [0 ].content .content
419- assert orphan_content is not None
420- assert any ("orphan thought" in s for s in orphan_content )
431+
432+ starts = [e for e in out if isinstance (e , StreamTaskMessageStart )]
433+ dones = [e for e in out if isinstance (e , StreamTaskMessageDone )]
434+ reasoning_fulls = [
435+ e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ReasoningContent )
436+ ]
437+ content_deltas = [
438+ e for e in out if isinstance (e , StreamTaskMessageDelta ) and isinstance (e .delta , ReasoningContentDelta )
439+ ]
440+
441+ assert len (starts ) == 1
442+ assert isinstance (starts [0 ].content , ReasoningContent )
443+ assert reasoning_fulls == []
444+ assert len (content_deltas ) == 1
445+ content_delta = content_deltas [0 ].delta
446+ assert isinstance (content_delta , ReasoningContentDelta )
447+ assert content_delta .content_delta == "orphan thought"
448+ assert len (dones ) == 1
449+ assert dones [0 ].index == starts [0 ].index
421450
422451 async def test_reasoning_summary_is_first_line (self ) -> None :
423452 events = [
@@ -428,9 +457,29 @@ async def test_reasoning_summary_is_first_line(self) -> None:
428457 },
429458 ]
430459 out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
431- full = next (e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ReasoningContent ))
432- assert isinstance (full .content , ReasoningContent )
433- assert full .content .summary == ["line one" ]
460+ summary_event = next (
461+ e for e in out if isinstance (e , StreamTaskMessageDelta ) and isinstance (e .delta , ReasoningSummaryDelta )
462+ )
463+ summary_delta = summary_event .delta
464+ assert isinstance (summary_delta , ReasoningSummaryDelta )
465+ assert summary_delta .summary_delta == "line one"
466+
467+ async def test_reasoning_empty_block_closes_with_done_only (self ) -> None :
468+ """A reasoning block that completes with no text still closes its Start."""
469+ events = [
470+ {"type" : "item.started" , "item" : {"id" : "r3" , "type" : "reasoning" , "text" : "" }},
471+ {"type" : "item.completed" , "item" : {"id" : "r3" , "type" : "reasoning" , "text" : "" }},
472+ ]
473+ out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
474+
475+ starts = [e for e in out if isinstance (e , StreamTaskMessageStart )]
476+ dones = [e for e in out if isinstance (e , StreamTaskMessageDone )]
477+ deltas = [e for e in out if isinstance (e , StreamTaskMessageDelta )]
478+
479+ assert len (starts ) == 1
480+ assert deltas == []
481+ assert len (dones ) == 1
482+ assert dones [0 ].index == starts [0 ].index
434483
435484
436485# ---------------------------------------------------------------------------
0 commit comments