Dynamic tool calls can emit item/started without item/completed when a turn is interrupted
What issue are you seeing?
When an App Server v2 dynamic tool call is waiting for the client response and the turn is interrupted, the server can emit item/started for the dynamicToolCall but never emit the matching item/completed.
The observed lifecycle is:
turn/started
item/started(type=dynamicToolCall, id=dyn-call-interrupt-1, status=inProgress)
item/tool/call(callId=dyn-call-interrupt-1) # client response remains pending
turn/interrupt -> {}
turn/completed(status=interrupted)
No item/completed for dyn-call-interrupt-1 is sent before the turn completes. A client that treats item/completed as the authoritative terminal state can therefore keep rendering this item as in progress after the turn has already become interrupted.
This appears inconsistent with the documented contract that the per-item lifecycle is always item/started followed by zero or more deltas and item/completed:
Environment:
openai/codex commit: 56395bddaf26eb2829387ca6a417bf9128e5b239
platform: macOS 26.3.2 arm64 (Darwin 25.3.0)
rustc: 1.94.0
App Server experimental API: enabled
I reproduced this in three independent test runs on that commit. The local nextest profile retried each failure once, so the same missing-terminal-item behavior was observed 6/6 times.
What steps can reproduce the bug?
- Initialize App Server v2 with
capabilities.experimentalApi = true. - Start a thread with a top-level function in
dynamicTools. - Start a turn whose mocked model response invokes that dynamic tool.
- Wait for both the
dynamicToolCallitem/startednotification and the matchingitem/tool/callserver request. - Keep the
item/tool/callrequest pending; do not send aDynamicToolCallResponse. - Send
turn/interruptwith the active thread and turn IDs. - Continue reading notifications through
turn/completed. - Check whether an
item/completedfor the same dynamic-tool call ID arrived before the interrupted turn completed.
I added a local App Server integration test named:
interrupting_pending_dynamic_tool_emits_terminal_item
and ran:
just test -p codex-app-server interrupting_pending_dynamic_tool_emits_terminal_item
Each run fails with the same assertion:
Error: missing item/completed for interrupted dynamic tool call
As a control, the normal response path passes on the same source tree:
just test -p codex-app-server dynamic_tool_call_round_trip_sends_text_content_items_to_model
What is the expected behavior?
Once a dynamic-tool item has emitted item/started, it should receive exactly one terminal item/completed before the corresponding turn/completed, including when the turn is interrupted while the client response is pending.
Based on the existing item model, I would expect the interrupted item to be terminal—likely status = "failed", success = false, with a cancellation error—followed by turn/completed(status = "interrupted"). If interruption is intended to be an exception to the documented item lifecycle, the protocol documentation should state that explicitly so clients can reconcile orphaned in-progress items.
Additional information
Root-cause hypothesis
This is a code-path hypothesis rather than a maintainer-confirmed root cause:
DynamicToolHandleruses the default runtime cancellation behavior.- The handler emits
item/started, directly awaits the client response channel, and only emitsitem/completedafter that await returns. waits_for_runtime_cancellation()defaults tofalse.- The outer runtime aborts handlers on cancellation when that flag is false.
This can abort the dynamic handler while it is awaiting the client response, before its failed terminal item is constructed and emitted.
Possible direction
One focused direction would be to let the dynamic handler observe invocation cancellation, opt into completing runtime cancellation teardown, emit one failed terminal item, and then return. A broader alternative would be for the App Server projection layer to synthesize terminal items for every started-but-unfinished item before turn/completed, but that has greater duplicate-completion and state-consistency risk.
It would be useful to confirm whether cancellation completion should be owned by the dynamic tool handler/core lifecycle or by the App Server projection layer, and what terminal status/error semantics are intended when a response and interrupt race.
I searched open and closed Issues and PRs for dynamicToolCall, pending dynamic tool interruption, and missing item/completed behavior and did not find an equivalent report.
I have traced the relevant cancellation and item-lifecycle paths and have a deterministic local regression test for this behavior. If this is considered a bug and the focused direction matches the intended cancellation semantics, I would be happy to implement the fix. Would the Codex team be willing to invite me to open a PR for it?
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗