@@ -47,6 +47,13 @@ async def _collect(stream: AsyncIterator[Any]) -> list[Any]:
4747 return [e async for e in stream ]
4848
4949
50+ def _result_text (event : StreamTaskMessageFull ) -> str :
51+ content = event .content
52+ assert isinstance (content , ToolResponseContent )
53+ assert isinstance (content .content , dict )
54+ return str (content .content ["result" ])
55+
56+
5057# ---------------------------------------------------------------------------
5158# Helpers
5259# ---------------------------------------------------------------------------
@@ -360,6 +367,107 @@ async def test_tool_indices_request_before_response(self) -> None:
360367 assert req .index < resp .index
361368
362369
370+ # ---------------------------------------------------------------------------
371+ # Progressive tool items (todo_list)
372+ # ---------------------------------------------------------------------------
373+
374+
375+ def _todo_item (item_id : str , * completed : bool ) -> dict [str , Any ]:
376+ return {
377+ "id" : item_id ,
378+ "type" : "todo_list" ,
379+ "items" : [{"text" : f"step { i + 1 } " , "completed" : done } for i , done in enumerate (completed )],
380+ }
381+
382+
383+ class TestTodoListUpdates :
384+ async def test_each_update_republishes_the_checklist (self ) -> None :
385+ """Codex ticks ONE in-place todo_list item, so every item.updated is
386+ forwarded as a response under the same tool_call_id. Without this a
387+ consumer only sees the plan as first written and then, at end of turn,
388+ as finished."""
389+ events = [
390+ {"type" : "item.started" , "item" : _todo_item ("item_1" , False , False )},
391+ {"type" : "item.updated" , "item" : _todo_item ("item_1" , True , False )},
392+ {"type" : "item.updated" , "item" : _todo_item ("item_1" , True , True )},
393+ {"type" : "item.completed" , "item" : _todo_item ("item_1" , True , True )},
394+ ]
395+ out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
396+
397+ requests = [
398+ e for e in out if isinstance (e , StreamTaskMessageStart ) and isinstance (e .content , ToolRequestContent )
399+ ]
400+ responses = [
401+ e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ToolResponseContent )
402+ ]
403+
404+ assert len (requests ) == 1
405+ assert len (responses ) == 3
406+
407+ request_content = requests [0 ].content
408+ assert isinstance (request_content , ToolRequestContent )
409+ for response in responses :
410+ content = response .content
411+ assert isinstance (content , ToolResponseContent )
412+ assert content .name == "todo_list"
413+ assert content .tool_call_id == request_content .tool_call_id
414+
415+ first , second , final = (json .loads (_result_text (r )) for r in responses )
416+ assert [i ["completed" ] for i in first ["items" ]] == [True , False ]
417+ assert [i ["completed" ] for i in second ["items" ]] == [True , True ]
418+ assert [i ["completed" ] for i in final ["items" ]] == [True , True ]
419+
420+ async def test_counts_the_call_once_across_its_updates (self ) -> None :
421+ events = [
422+ {"type" : "item.started" , "item" : _todo_item ("item_1" , False )},
423+ {"type" : "item.updated" , "item" : _todo_item ("item_1" , True )},
424+ {"type" : "item.completed" , "item" : _todo_item ("item_1" , True )},
425+ ]
426+ counters : dict [str , Any ] = {}
427+ await _collect (convert_codex_to_agentex_events (_aiter (events ), on_result = counters .update ))
428+ assert counters .get ("tool_call_count" ) == 1
429+
430+ async def test_ignores_an_update_for_an_unopened_item (self ) -> None :
431+ """An update with no preceding start has no request to answer; a
432+ response would dangle with a tool_call_id nothing points at."""
433+ events = [{"type" : "item.updated" , "item" : _todo_item ("orphan" , True )}]
434+ out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
435+ assert [e for e in out if isinstance (e , StreamTaskMessageFull )] == []
436+
437+ async def test_does_not_republish_other_tool_items (self ) -> None :
438+ events = [
439+ {
440+ "type" : "item.started" ,
441+ "item" : {"id" : "cmd1" , "type" : "command_execution" , "command" : "sleep 1" },
442+ },
443+ {
444+ "type" : "item.updated" ,
445+ "item" : {
446+ "id" : "cmd1" ,
447+ "type" : "command_execution" ,
448+ "command" : "sleep 1" ,
449+ "aggregated_output" : "partial" ,
450+ },
451+ },
452+ {
453+ "type" : "item.completed" ,
454+ "item" : {
455+ "id" : "cmd1" ,
456+ "type" : "command_execution" ,
457+ "command" : "sleep 1" ,
458+ "aggregated_output" : "done" ,
459+ "exit_code" : 0 ,
460+ },
461+ },
462+ ]
463+ out = await _collect (convert_codex_to_agentex_events (_aiter (events )))
464+ responses = [
465+ e for e in out if isinstance (e , StreamTaskMessageFull ) and isinstance (e .content , ToolResponseContent )
466+ ]
467+ assert len (responses ) == 1
468+ assert _result_text (responses [0 ]) == "done"
469+
470+
363471# ---------------------------------------------------------------------------
364472# Reasoning
365473# ---------------------------------------------------------------------------
0 commit comments