Skip to content

Commit 078bfad

Browse files
declan-scaleclaude
andcommitted
feat(codex): republish todo_list revisions as they are ticked off
Codex does not reopen its plan: one `todo_list` item is opened at the start of a turn and revised in place through `item.updated`, with `item.completed` only arriving at the very end. The tap dropped those updates, so a consumer saw the plan exactly twice — as first written (nothing done) and then, at end of turn, fully finished. Forward each revision as a `ToolResponseContent` under the item's existing `tool_call_id`. Consumers that key responses by call id (the GenAI Ops Hub chat does) now render the checklist filling in as the turn runs, and the last response received is still the final state. Scoped to `todo_list` via `_PROGRESSIVE_TOOL_ITEMS`: the other tool item types carry no intermediate state worth acting on, and republishing `command_execution` output on every chunk would be pure noise. Updates for an item that was never opened are ignored — there is no request for them to answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 77a1316 commit 078bfad

2 files changed

Lines changed: 151 additions & 4 deletions

File tree

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@
6060
a synthetic ToolRequestContent Full is emitted before the response.
6161
mcp_tool_call -> same as command_execution
6262
web_search -> same as command_execution
63-
todo_list -> same as command_execution
63+
todo_list -> same as command_execution, plus:
64+
item.updated -> StreamTaskMessageFull(ToolResponseContent)
65+
Codex ticks one in-place todo_list item through
66+
item.updated; each revision is republished under
67+
the same tool_call_id so consumers can render the
68+
checklist filling in rather than jumping to its
69+
final state at end of turn.
6470
collab_tool_call -> same as command_execution
6571
error (item type) -> StreamTaskMessageFull(TextContent, error text) on completed only
6672
@@ -75,9 +81,10 @@
7581
without this module needing to know about spans.
7682
item.updated (reasoning): the intermediate cumulative text is discarded;
7783
only item.completed carries the final text.
78-
item.updated (tool): tool item types other than agent_message do not
79-
emit updates; item.started opens the request and
80-
item.completed closes it.
84+
item.updated (tool): only todo_list is republished (see above). For the
85+
other tool item types item.started opens the request
86+
and item.completed closes it; any updates in between
87+
carry no state a consumer could act on.
8188
"""
8289

8390
from __future__ import annotations
@@ -112,6 +119,11 @@ def _truncate(text: str, max_len: int = _MAX_RESULT_LENGTH) -> str:
112119
return str(text)[:max_len]
113120

114121

122+
# Tool items codex revises in place rather than reopening. Their item.updated
123+
# events carry real intermediate state, so they are forwarded as responses.
124+
_PROGRESSIVE_TOOL_ITEMS = frozenset({"todo_list"})
125+
126+
115127
def _tool_name_for(item_type: str, payload: dict[str, Any]) -> str:
116128
"""Derive a canonical tool name from a codex item type."""
117129
if item_type == "command_execution":
@@ -467,6 +479,33 @@ def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMe
467479
)
468480
out.append(StreamTaskMessageDone(type="done", index=req_idx))
469481

482+
elif evt_type == "item.updated" and item_type in _PROGRESSIVE_TOOL_ITEMS:
483+
# Codex revises its plan in place: one todo_list item is opened
484+
# at the start of the turn and ticked off through item.updated,
485+
# with item.completed only arriving at the very end. Forwarding
486+
# each revision as a response for the SAME tool_call_id lets a
487+
# consumer show the checklist filling in as the turn runs; the
488+
# last response received is the current state.
489+
if item_id in self._tool_open:
490+
actual_type = self._tool_item_types.get(item_id, item_type)
491+
result_text, is_error = _tool_output_for(actual_type, item)
492+
resp_content: dict[str, Any] = {"result": result_text}
493+
if is_error:
494+
resp_content["is_error"] = True
495+
out.append(
496+
StreamTaskMessageFull(
497+
type="full",
498+
index=self._alloc(),
499+
content=ToolResponseContent(
500+
type="tool_response",
501+
author="agent",
502+
tool_call_id=tool_call_id,
503+
name=_tool_name_for(actual_type, item),
504+
content=resp_content,
505+
),
506+
)
507+
)
508+
470509
elif evt_type == "item.completed":
471510
# file_change items may only emit item.completed (no started).
472511
if item_id not in self._tool_open:

tests/lib/adk/test_codex_sync.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)