Dynamic tool calls can emit item/started without item/completed when a turn is interrupted

Open 💬 2 comments Opened Jul 18, 2026 by Finn7X

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?

  1. Initialize App Server v2 with capabilities.experimentalApi = true.
  2. Start a thread with a top-level function in dynamicTools.
  3. Start a turn whose mocked model response invokes that dynamic tool.
  4. Wait for both the dynamicToolCall item/started notification and the matching item/tool/call server request.
  5. Keep the item/tool/call request pending; do not send a DynamicToolCallResponse.
  6. Send turn/interrupt with the active thread and turn IDs.
  7. Continue reading notifications through turn/completed.
  8. Check whether an item/completed for 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:

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?

View original on GitHub ↗

2 Comments

realmehmetali · 1 month ago

I independently traced this on 56395bddaf26eb2829387ca6a417bf9128e5b239. The handler-owned direction looks right, with one important implementation caveat: overriding waits_for_runtime_cancellation() alone would not close the lifecycle gap.

The current ordering creates a small dependency cycle:

  1. request_dynamic_tool() waits only on rx_response; it does not observe the invocation cancellation token.
  2. handle_task_abort() cancels the task, waits up to 100 ms for graceful completion, then aborts it.
  3. The pending dynamic-tool sender is dropped only later: abort_all_tasks() calls clear_pending() after handle_task_abort() returns, and clear_pending_waiters() clears pending_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/completed can still be skipped.

A focused fix contract therefore seems to need both pieces:

  • Override DynamicToolHandler::waits_for_runtime_cancellation() to return true, so the outer runtime lets the handler finish teardown.
  • Pass invocation.cancellation_token into request_dynamic_tool() and select! between rx_response and cancellation. The cancellation branch should construct and emit the existing failed DynamicToolCallItem before returning None.

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:

  • pending response + interrupt → one failed item/completed, before turn/completed;
  • response wins immediately before interrupt → one completed item, never a second terminal event;
  • interrupt wins + late client response → the late response cannot produce another terminal item;
  • two concurrent pending dynamic calls + interrupt → every started call is terminalized exactly once;
  • started/completed IDs match and notification ordering is preserved.

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 inProgress item 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/codex repository and synthetic protocol reasoning; it contains no private code, data, prompts, roadmaps, or product-specific examples.

Finn7X · 1 month ago

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.