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?
2 Comments
I independently traced this on
56395bddaf26eb2829387ca6a417bf9128e5b239. The handler-owned direction looks right, with one important implementation caveat: overridingwaits_for_runtime_cancellation()alone would not close the lifecycle gap.The current ordering creates a small dependency cycle:
request_dynamic_tool()waits only onrx_response; it does not observe the invocation cancellation token.handle_task_abort()cancels the task, waits up to 100 ms for graceful completion, then aborts it.abort_all_tasks()callsclear_pending()afterhandle_task_abort()returns, andclear_pending_waiters()clearspending_dynamic_tools.So if the handler merely opts into runtime cancellation teardown, it can still wait on a channel that cannot close until after that teardown wait. The 100 ms grace period expires, the task is forcibly aborted, and
item/completedcan still be skipped.A focused fix contract therefore seems to need both pieces:
DynamicToolHandler::waits_for_runtime_cancellation()to returntrue, so the outer runtime lets the handler finish teardown.invocation.cancellation_tokenintorequest_dynamic_tool()andselect!betweenrx_responseand cancellation. The cancellation branch should construct and emit the existing failedDynamicToolCallItembefore returningNone.I would keep terminalization in the handler rather than synthesize it in App Server. The handler owns the original arguments, duration, response success/content, and the single completion point; projection-layer synthesis would need separate duplicate-suppression state and could diverge for non-App-Server consumers.
The response/interrupt race deserves an explicit contract too. At minimum, I would cover:
item/completed, beforeturn/completed;This is an important invariant for frontier agent runtimes, not just a UI cleanup: client-hosted tools are a composition boundary for orchestrators. An orphaned
inProgressitem makes retry, recovery, and audit logic ambiguous precisely when cancellation is being used to regain control. Preserving “one start, exactly one terminal event” makes dynamic tools safe to compose under interruption.Disclosure: this analysis was produced with significant assistance from OpenAI Codex. It is based only on the public
openai/codexrepository and synthetic protocol reasoning; it contains no private code, data, prompts, roadmaps, or product-specific examples.Thanks for the independent trace and the detailed teardown analysis. I’ll incorporate the response/interrupt race cases and concurrent pending-call coverage into the regression-test plan if the maintainers confirm the direction and invite a PR.