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,58 @@ 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+ assert content_deltas [0 ].delta .content_delta == "thinking... done"
409+ assert len (summary_deltas ) == 1
410+ assert summary_deltas [0 ].delta .summary_delta == "thinking... done"
411+ assert len (dones ) == 1
412+ idx = starts [0 ].index
413+ assert content_deltas [0 ].index == idx
414+ assert summary_deltas [0 ].index == idx
415+ assert dones [0 ].index == idx
416+
417+ async def test_reasoning_no_started_opens_and_closes_one_message (self ) -> None :
418+ """If item.completed arrives without item.started, the converter opens a
419+ Start lazily and closes it with a Done (still one clean message, no Full)."""
408420 events = [
409421 {
410422 "type" : "item.completed" ,
411423 "item" : {"id" : "r_orphan" , "type" : "reasoning" , "text" : "orphan thought" },
412424 }
413425 ]
414426 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 )
427+
428+ starts = [e for e in out if isinstance (e , StreamTaskMessageStart )]
429+ dones = [e for e in out if isinstance (e , StreamTaskMessageDone )]
430+ reasoning_fulls = [
431+ e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ReasoningContent )
432+ ]
433+ content_deltas = [
434+ e for e in out if isinstance (e , StreamTaskMessageDelta ) and isinstance (e .delta , ReasoningContentDelta )
435+ ]
436+
437+ assert len (starts ) == 1
438+ assert isinstance (starts [0 ].content , ReasoningContent )
439+ assert reasoning_fulls == []
440+ assert len (content_deltas ) == 1
441+ assert content_deltas [0 ].delta .content_delta == "orphan thought"
442+ assert len (dones ) == 1
443+ assert dones [0 ].index == starts [0 ].index
421444
422445 async def test_reasoning_summary_is_first_line (self ) -> None :
423446 events = [
@@ -428,9 +451,27 @@ async def test_reasoning_summary_is_first_line(self) -> None:
428451 },
429452 ]
430453 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" ]
454+ summary_delta = next (
455+ e for e in out if isinstance (e , StreamTaskMessageDelta ) and isinstance (e .delta , ReasoningSummaryDelta )
456+ )
457+ assert summary_delta .delta .summary_delta == "line one"
458+
459+ async def test_reasoning_empty_block_closes_with_done_only (self ) -> None :
460+ """A reasoning block that completes with no text still closes its Start."""
461+ events = [
462+ {"type" : "item.started" , "item" : {"id" : "r3" , "type" : "reasoning" , "text" : "" }},
463+ {"type" : "item.completed" , "item" : {"id" : "r3" , "type" : "reasoning" , "text" : "" }},
464+ ]
465+ out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
466+
467+ starts = [e for e in out if isinstance (e , StreamTaskMessageStart )]
468+ dones = [e for e in out if isinstance (e , StreamTaskMessageDone )]
469+ deltas = [e for e in out if isinstance (e , StreamTaskMessageDelta )]
470+
471+ assert len (starts ) == 1
472+ assert deltas == []
473+ assert len (dones ) == 1
474+ assert dones [0 ].index == starts [0 ].index
434475
435476
436477# ---------------------------------------------------------------------------
0 commit comments