Codex Desktop composer can double-dispatch one submit, creating two threads or duplicate steered messages

Open 💬 0 comments Opened Jul 16, 2026 by yky95

What version of the Codex App are you using (From “About Codex” dialog)?

26.707.91948 (build 5440)

What subscription do you have?

ChatGPT Enterprise / managed workspace (exact plan label is not shown in the Codex App)

What platform is your computer?

Darwin 25.5.0 arm64 arm

What issue are you seeing?

On macOS, one user-visible submit intermittently emits two independent RPCs from the same focused renderer.

I captured two manifestations in the same app session:

  1. New-task composer: two independent thread/start requests were emitted from originWebcontentsId=1 with different request IDs. The responses were 14 ms apart, and two persistent threads were created about 1 ms apart. Both threads came from the same frontend client-new-thread:<uuid> draft, and local prompt history recorded the same prompt twice.
  2. Existing thread: two independent turn/start requests were emitted from the same renderer. The app-server accepted two distinct UserInput submissions 30.4 ms apart. The second input was queued/steered into the active turn and only became visible about 31 seconds later, making it look as if the message had been sent again later.

There was no submission error, timeout, reconnect, or retry warning. Both requests came from the same focused primary window and the same single app-server process. Prompts, usernames, hostnames, IPs, project paths, and session IDs are intentionally redacted.

What steps can reproduce the bug?

The issue is intermittent rather than deterministic, but it was captured twice with local Desktop and app-server logs.

  1. Open Codex Desktop on macOS and select an SSH remote project.
  2. Open the new-task composer.
  3. Enter a prompt and activate Send once.
  4. Occasionally, two tasks with the same title and prompt are created.
  5. In an existing idle thread, enter a follow-up and activate Send once.
  6. Occasionally, two turn/start calls are emitted within tens of milliseconds. The second message may appear later because it is delivered through the active-turn steer/input queue.

Observed app: 26.707.91948 (build 5440).
Embedded Codex CLI: 0.144.5.
Remote app-server in the captured case: 0.144.1.

What is the expected behavior?

One user submit gesture must result in exactly one logical submission:

  • A new-task submit creates one persistent thread.
  • An existing-thread submit adds one user message.
  • Duplicate callbacks or transport retries must be idempotent and return the original thread/turn.
  • Sending the same text again in a later, intentional gesture must remain possible because it receives a new submission ID.

Additional information

Source-level evidence

The packaged Desktop submit handler has no synchronous single-flight guard. In:

app.asar :: webview/assets/app-initial~app-main~new-thread-panel-page~appgen-library-page~hotkey-window-thread-page~ho~iufn7mg3-DtuASjaM.js

the async submit path is effectively:

await prepareGoalSubmit(...)
appendPromptToHistory(...)
await Promise.all([buildLocalContext(...), collectThreadReferences(...)])
setIsSubmitting(true)
await submitTarget.submit(...)

The handler entry is around byte 338820; the first normal-path setIsSubmitting(true) is around byte 341182. A second callback can therefore enter while the first is awaiting preparation. React state/button disabling is too late to be a same-tick mutex.

The app-server has no backstop:

  • In rust-v0.144.1, ThreadStartParams has no client idempotency key:

https://github.com/openai/codex/blob/rust-v0.144.1/codex-rs/app-server-protocol/src/protocol/v2/thread.rs#L56-L148

  • Every thread/start directly creates a new thread:

https://github.com/openai/codex/blob/rust-v0.144.1/codex-rs/app-server/src/request_processors/thread_processor.rs#L1212-L1244

  • turn/start.clientUserMessageId is copied into pending input but is not used for deduplication:

https://github.com/openai/codex/blob/rust-v0.144.1/codex-rs/core/src/session/mod.rs#L3931-L3946

Suggested defense-in-depth fix

  1. Set a synchronous useRef/mutex at the first line of the composer submit handler, before any await.
  2. Generate one stable client submission ID per user gesture/draft and reuse it across retries.
  3. Pass that ID through thread/start; repeated requests should return the original thread.
  4. Deduplicate turn/start by clientUserMessageId and return the original turn instead of enqueueing again.
  5. Add concurrent-submit tests such as Promise.all([submit(), submit()]).

Related but materially different: #19951 involves visible submission errors and explicit user retries. This report has no error or manual resubmit; the two RPCs originate from the same renderer within tens of milliseconds.

The exact physical event source (keyboard/IME/button) is not present in current logs, but a synchronous frontend guard plus protocol-level idempotency would make that source irrelevant.

View original on GitHub ↗