Skip to content

Commit 1d86e8a

Browse files
authored
fix(harness): correct codex & openai reasoning stream envelopes (#441)
1 parent 5954a9f commit 1d86e8a

3 files changed

Lines changed: 148 additions & 72 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))

src/agentex/lib/adk/providers/_modules/sync_provider.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from agentex import AsyncAgentex
3333
from agentex.lib.utils.logging import make_logger
3434
from agentex.lib.core.tracing.tracer import AsyncTracer
35+
from agentex.types.reasoning_content import ReasoningContent
3536
from agentex.types.task_message_delta import TextDelta
3637
from agentex.types.task_message_update import (
3738
StreamTaskMessageDone,
@@ -560,14 +561,20 @@ async def convert_openai_to_agentex_events(stream_response):
560561
item_id_to_index[item_id] = message_index
561562
item_id_to_type[item_id] = "reasoning_summary"
562563

563-
# Send a start event for this new reasoning summary message
564+
# Send a start event for this new reasoning summary message.
565+
# The start content must be ReasoningContent (not TextContent)
566+
# so consumers that branch on the start event's content type
567+
# render a reasoning/thinking indicator; the final persisted
568+
# content is rebuilt from the reasoning deltas regardless.
564569
yield StreamTaskMessageStart(
565570
type="start",
566571
index=item_id_to_index[item_id],
567-
content=TextContent(
568-
type="text",
572+
content=ReasoningContent(
573+
type="reasoning",
569574
author="agent",
570-
content="", # Start with empty content
575+
summary=[],
576+
content=[],
577+
style="active",
571578
),
572579
)
573580

@@ -604,14 +611,20 @@ async def convert_openai_to_agentex_events(stream_response):
604611
item_id_to_index[item_id] = message_index
605612
item_id_to_type[item_id] = "reasoning_content"
606613

607-
# Send a start event for this new reasoning content message
614+
# Send a start event for this new reasoning content message.
615+
# The start content must be ReasoningContent (not TextContent)
616+
# so consumers that branch on the start event's content type
617+
# render a reasoning/thinking indicator; the final persisted
618+
# content is rebuilt from the reasoning deltas regardless.
608619
yield StreamTaskMessageStart(
609620
type="start",
610621
index=item_id_to_index[item_id],
611-
content=TextContent(
612-
type="text",
622+
content=ReasoningContent(
623+
type="reasoning",
613624
author="agent",
614-
content="", # Start with empty content
625+
summary=[],
626+
content=[],
627+
style="active",
615628
),
616629
)
617630

tests/lib/adk/test_codex_sync.py

Lines changed: 81 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,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

Comments
 (0)