Skip to content

Commit 7bcca10

Browse files
declan-scaleclaude
andcommitted
fix(codex): close reasoning with Done deltas instead of Full to avoid duplicate message
Codex reasoning emitted Start(active) then Full(static) at the open index with no Done. auto_send routes a Full into its own throwaway streaming context (ignoring the index), so the Start context survived until end-of-turn teardown and persisted a second, near-empty reasoning message (user-visible duplicate + out-of-order). Mirror the claude_code pattern: stream the final reasoning as summary + content deltas on the open index, then close with a Done, so the open context accumulates the final ReasoningContent and closes cleanly as one message. The no-started case opens the Start lazily and closes it the same way. Updated tests assert the Start + deltas + Done sequence and that no Full(ReasoningContent) is emitted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5954a9f commit 7bcca10

2 files changed

Lines changed: 119 additions & 64 deletions

File tree

src/agentex/lib/adk/_modules/_codex_sync.py

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
reasoning -> reasoning:
4949
item.started -> StreamTaskMessageStart(ReasoningContent)
5050
item.updated -> (no-op; final text arrives on completed)
51-
item.completed -> StreamTaskMessageFull(ReasoningContent)
51+
item.completed -> StreamTaskMessageDelta(ReasoningSummaryDelta)
52+
+ StreamTaskMessageDelta(ReasoningContentDelta)
53+
+ StreamTaskMessageDone
5254
command_execution -> tool request + response:
5355
item.started -> StreamTaskMessageStart(ToolRequestContent)
5456
+ StreamTaskMessageDone
@@ -96,6 +98,7 @@
9698
from agentex.types.tool_request_content import ToolRequestContent
9799
from agentex.types.tool_response_content import ToolResponseContent
98100
from agentex.types.reasoning_content_delta import ReasoningContentDelta
101+
from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta
99102

100103
logger = make_logger(__name__)
101104

@@ -366,18 +369,6 @@ def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMe
366369
),
367370
)
368371
)
369-
if current:
370-
out.append(
371-
StreamTaskMessageDelta(
372-
type="delta",
373-
index=idx,
374-
delta=ReasoningContentDelta(
375-
type="reasoning_content",
376-
content_index=0,
377-
content_delta=current,
378-
),
379-
)
380-
)
381372

382373
elif evt_type == "item.updated":
383374
# Accumulate silently; final text arrives on item.completed.
@@ -389,30 +380,53 @@ def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMe
389380
if text:
390381
self.reasoning_count += 1
391382
summary = text.strip().split("\n", 1)[0][:300]
392-
final_content = ReasoningContent(
393-
type="reasoning",
394-
author="agent",
395-
summary=[summary],
396-
content=[text],
397-
style="static",
398-
)
399-
if idx is not None:
383+
if idx is None:
384+
# No started event was seen; open the message now.
385+
idx = self._alloc()
400386
out.append(
401-
StreamTaskMessageFull(
402-
type="full",
387+
StreamTaskMessageStart(
388+
type="start",
403389
index=idx,
404-
content=final_content,
390+
content=ReasoningContent(
391+
type="reasoning",
392+
author="agent",
393+
summary=[],
394+
content=[],
395+
style="active",
396+
),
405397
)
406398
)
407-
else:
408-
# No started event was seen; emit a standalone Full.
409-
out.append(
410-
StreamTaskMessageFull(
411-
type="full",
412-
index=self._alloc(),
413-
content=final_content,
414-
)
399+
# Deliver the reasoning as deltas, then close with a Done.
400+
# Emitting a Full here instead would leave the open Start
401+
# context dangling: auto_send routes Full into its own
402+
# throwaway streaming context (ignoring the index), so the
403+
# Start context survives until end-of-turn teardown and
404+
# persists a second, near-empty reasoning message. Streaming
405+
# the content as deltas lets the open context accumulate the
406+
# final ReasoningContent and close cleanly as one message.
407+
out.append(
408+
StreamTaskMessageDelta(
409+
type="delta",
410+
index=idx,
411+
delta=ReasoningSummaryDelta(
412+
type="reasoning_summary",
413+
summary_index=0,
414+
summary_delta=summary,
415+
),
415416
)
417+
)
418+
out.append(
419+
StreamTaskMessageDelta(
420+
type="delta",
421+
index=idx,
422+
delta=ReasoningContentDelta(
423+
type="reasoning_content",
424+
content_index=0,
425+
content_delta=text,
426+
),
427+
)
428+
)
429+
out.append(StreamTaskMessageDone(type="done", index=idx))
416430
elif idx is not None:
417431
# Empty reasoning block — still need to close with a Done.
418432
out.append(StreamTaskMessageDone(type="done", index=idx))

tests/lib/adk/test_codex_sync.py

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
convert_codex_to_agentex_events,
3636
)
3737
from agentex.types.reasoning_content_delta import ReasoningContentDelta
38+
from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta
3839

3940

4041
async def _aiter(items: list[Any]) -> AsyncIterator[Any]:
@@ -365,7 +366,15 @@ async def test_tool_indices_request_before_response(self) -> None:
365366

366367

367368
class 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

Comments
 (0)