Skip to content

feat: Codex voice conversations on web, desktop, and mobile - #14638

Closed
jakeleventhal wants to merge 7 commits into
pingdotgg:t3code/codex-turn-mappingfrom
jakeleventhal:t3code/codex-voice-mode-rebased
Closed

jakeleventhal wants to merge 7 commits into
pingdotgg:t3code/codex-turn-mappingfrom
jakeleventhal:t3code/codex-voice-mode-rebased

Conversation

@jakeleventhal

Copy link
Copy Markdown
Contributor

Replaces #13168, which was closed under the prior-approval rule. The code is the same change rebased onto the current t3code/codex-turn-mapping, plus one small commit that follows the base's new namespace imports at three call sites. #13168 showed ~1.6k files only because the base was force-pushed underneath it; the real diff is 60 files.

Screen.Recording.2026-09-22.at.8.13.50.PM.mov
ScreenRecording_09-22-2026.20-15-53_1.MP4

Codex 0.156 shipped voice conversations: you talk to a realtime voice model, it hands coding work to Codex in the thread, and it reads Codex's answer back. T3 Code had no way to do this.

Scope and approval

  • Closure being addressed: #13168 (comment), which asked for maintainer approval of the direction and scope to be linked.
  • Julius saw feat: Codex voice conversations on web, desktop, and mobile #13168 on Discord on Sept 22 and replied "cool", then "will think on the UI a bit". That reply is the approval record I have; there is no approval comment on GitHub to link. I read it as a go on the direction with the UI still open, and I'm happy to rework the UI to whatever you land on.

Julius's Discord reply to #13168 on Sept 22: "cool", then "will think on the UI a bit"

How it works

  • Audio stays off the T3 socket. The client owns the mic, speaker, and WebRTC peer (oai-events data channel), and audio flows directly between the client and OpenAI. The new provider.voice.session stream RPC carries only the SDP handshake, captions, and lifecycle, so voice costs the same locally, over a relay, or through a tunnel.
  • The stream is the conversation. Unsubscribing or disconnecting stops the Codex realtime session, so a dropped client can't leave a billed session running.
  • Server (CodexVoice.ts, CodexAdapterV2). Starts thread/realtime/start with the same V3 WebRTC, client-managed-handoff settings the Codex TUI uses. When a voice turn finishes, its final answer goes back through thread/realtime/appendSpeech; commentary and reasoning are never spoken.
  • Idle-thread handoffs. Codex starts or steers the delegated turn itself, and that can't be turned off. Steering an active run needs nothing special. On an idle thread V2 has no provider-initiated run, so the adapter interrupts Codex's turn and, once it settles, offers a voice continuation. The request then runs as an ordinary user message. The tradeoff: Codex's own history keeps a stub of the interrupted turn. First-class provider-initiated runs would remove this.
  • Clients. A shared VoiceModeController in client-runtime handles negotiation, mute, captions, mic/speaker levels, the connect timeout, and teardown. Browsers and react-native-webrtc share the same structural peer API.
  • Web/desktop. Waveform button in the composer, /voice, F8 (voice.toggle), an unbound voice.toggleMute, command palette actions, and a voice strip with status, meters, mute/stop, and live captions. Captions are ephemeral.
  • Desktop. Grants mic access to the app origin only, asks macOS on first use, and adds NSMicrophoneUsageDescription and the audio-input entitlement.
  • Mobile. Adds react-native-webrtc (native rebuild required), Android RECORD_AUDIO, loudspeaker routing, and the same button and strip. Conversations stop on background or when leaving the thread.
  • Settings and scope. Voice is chosen in Codex provider settings. It's Codex-only (ServerProvider.supportsVoice) and needs a thread whose provider session is running.

Verification

Re-run on this branch after the rebase:

  • tsc --noEmit is clean in contracts, shared, client-runtime, server, web, desktop, and mobile.
  • Focused tests on the test files this diff touches: 235 server (voice module, V2 Codex adapter, continuation service, keybindings, provider registry, settings), 233 web, 76 desktop artifact script, 40 desktop, 12 shared, 7 client-runtime, and 5 mobile. All pass.
  • vp lint and vp fmt --check on the changed files: clean.

Carried over from #13168, not repeated on this branch:

  • Codex 0.156's app-server accepts the exact start params.
  • Installed and launched on desktop and iPhone from a personal build. The recordings above are from that build. A full spoken round trip has not been verified yet.

Done by Claude Opus 5.5 in Claude Code, running in T3 Code.

🤖 Generated with Claude Code

jakeleventhal and others added 7 commits October 1, 2026 15:03
Codex 0.156 voice mode for T3 Code: talk to a realtime voice model that hands
work to Codex in the thread and reads its answers back. Audio runs over WebRTC
between the client and OpenAI; the server relays only SDP and captions over
provider.voice.session, and ending the stream stops the conversation.

On orchestrator V2, Codex's self-started voice turns are interrupted and
resubmitted as ordinary user runs, since V2 has no provider-initiated runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Resubmit a Codex-started voice turn only once it reports interrupted, so a
  turn that finished before the interrupt landed never runs twice.
- Carry each pending resubmit's own provider thread, and keep a replaced
  voice stream from clearing its successor's thread.
- Mobile: gate voice on the provider thread existing, keep the voice strip
  reachable while a question card hides the composer, and keep a newer
  conversation's wake lock.
- Drop unused exports flagged by knip and update the Codex settings fields test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…versation

- Resubmit a Codex-started voice turn that failed, not only one that was
  interrupted, so the spoken request is never silently dropped.
- /voice stops an active conversation even if the provider no longer reports
  voice support.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ional shortcut

Startup default sync only treated identical `when` strings as conflicts, so a
default like `f8 voice.toggle when !terminalFocus` was appended after a user's
bare `f8` binding and won whenever the terminal lacked focus. A default is now
skipped when its shortcut matches a user rule and either side is unconditional.

Also corrects the voice lifecycle comment: screens pushed over the thread keep
a conversation running.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The base now imports IdAllocator, ProviderContinuationRequests, and
EnvironmentRegistry as namespaces. Update the three voice call sites that
still used the old named imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Oct 1, 2026
});
});
session.setPermissionCheckHandler((_webContents, permission, requestingOrigin) =>
permission === "media" ? isAppUrl(requestingOrigin, options.appUrl) : true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High window/MainWindowPermissions.ts:61

Trusted media checks return true, so Electron skips setPermissionRequestHandler and never calls askForMediaAccess("microphone"). On macOS, users with microphoneStatus() set to "not-determined" therefore receive no system consent prompt and voice capture remains unauthorized. Return false for media checks so the request handler can grant or deny the request and trigger the prompt.

-    permission === "media" ? isAppUrl(requestingOrigin, options.appUrl) : true,
+    permission === "media" ? false : true,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/window/MainWindowPermissions.ts around line 61:

Trusted `media` checks return `true`, so Electron skips `setPermissionRequestHandler` and never calls `askForMediaAccess("microphone")`. On macOS, users with `microphoneStatus()` set to `"not-determined"` therefore receive no system consent prompt and voice capture remains unauthorized. Return `false` for `media` checks so the request handler can grant or deny the request and trigger the prompt.

const expectedIndex =
text === undefined
? -1
: active.expectedHandoffs.findIndex((expected) => text.includes(expected));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/CodexVoice.ts:190

A normal user turn can consume a pending voice handoff and have its reply spoken instead, while the actual continuation is no longer tracked and its answer is not read aloud. expectSpokenTurn stores only the request text, and the fallback at findIndex matches with text.includes(expected), so any queued user message containing the same phrase—such as run the tests—is treated as the handoff. Correlate the expected handoff with the specific continuation/turn identifier rather than matching message text.

Also found in 1 other location(s)

apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts:5367

voice.expectSpokenTurn records only the request text, not an identifier for the continuation it is about to enqueue. Before that queued continuation starts, any ordinary user turn on the voice thread whose message contains the same text is consumed by CodexVoice as the expected handoff. Its reply is spoken instead, while the actual voice continuation is no longer tracked and its answer is not read aloud. This is reachable when a user submits the same/containing text while the continuation worker is queued behind another turn.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/CodexVoice.ts around line 190:

A normal user turn can consume a pending voice handoff and have its reply spoken instead, while the actual continuation is no longer tracked and its answer is not read aloud. `expectSpokenTurn` stores only the request text, and the fallback at `findIndex` matches with `text.includes(expected)`, so any queued user message containing the same phrase—such as `run the tests`—is treated as the handoff. Correlate the expected handoff with the specific continuation/turn identifier rather than matching message text.

Also found in 1 other location(s):
- apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts:5367 -- `voice.expectSpokenTurn` records only the request text, not an identifier for the continuation it is about to enqueue. Before that queued continuation starts, any ordinary user turn on the voice thread whose message contains the same text is consumed by `CodexVoice` as the expected handoff. Its reply is spoken instead, while the actual voice continuation is no longer tracked and its answer is not read aloud. This is reachable when a user submits the same/containing text while the continuation worker is queued behind another turn.

return resolveVoiceAvailability({
provider: providers.find((provider) => provider.instanceId === thread.providerInstanceId),
hasMessages: thread.latestUserMessageAt !== null,
hasSession: thread.activeProviderThreadId !== null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium voice/voiceAvailability.ts:44

resolveThreadVoiceAvailability returns ready after the provider session has been torn down, so the UI enables voice and requests microphone access even though the subsequent voice RPC rejects because providerSessionsV2.get(providerSessionId) is null. activeProviderThreadId only proves that a persisted provider-thread record exists; use the live provider-session state when computing hasSession so this path returns unavailable instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/voice/voiceAvailability.ts around line 44:

`resolveThreadVoiceAvailability` returns `ready` after the provider session has been torn down, so the UI enables voice and requests microphone access even though the subsequent voice RPC rejects because `providerSessionsV2.get(providerSessionId)` is `null`. `activeProviderThreadId` only proves that a persisted provider-thread record exists; use the live provider-session state when computing `hasSession` so this path returns `unavailable` instead.

Comment on lines +262 to +269
const previous = yield* Ref.getAndSet(activeRef, active);
if (previous && !previous.closed) {
// One conversation per thread: a newer client takes over.
yield* finish(previous, { type: "closed", reason: "replaced" });
yield* client.raw
.request("thread/realtime/stop", { threadId: previous.providerThreadId })
.pipe(Effect.ignore);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/CodexVoice.ts:262

Taking over a thread publishes the replacement active before the previous realtime transport is marked closed and stopped, so delayed thread/realtime/closed or thread/realtime/error notifications from the old transport are routed to and terminate the new session. Mark and stop the previous session before installing the replacement in activeRef.

-        const previous = yield* Ref.getAndSet(activeRef, active);
+        const previous = yield* Ref.get(activeRef);
         if (previous && !previous.closed) {
           // One conversation per thread: a newer client takes over.
           yield* finish(previous, { type: "closed", reason: "replaced" });
           yield* client.raw
             .request("thread/realtime/stop", { threadId: previous.providerThreadId })
             .pipe(Effect.ignore);
         }
+        yield* Ref.set(activeRef, active);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/CodexVoice.ts around lines 262-269:

Taking over a thread publishes the replacement `active` before the previous realtime transport is marked closed and stopped, so delayed `thread/realtime/closed` or `thread/realtime/error` notifications from the old transport are routed to and terminate the new session. Mark and stop the previous session before installing the replacement in `activeRef`.

if (!offer.sdp) throw new Error("Could not create a voice connection offer.");

session.cancelSession = this.platform.openSession(
{ ...target, offerSdp: offer.sdp },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High voice-mode/controller.ts:297

The server receives an SDP offer without the ICE candidates gathered after setLocalDescription, so voice connections time out on normal NATed or remote networks. openSession is called immediately with offer.sdp, and this controller neither waits for ICE gathering to complete nor forwards icecandidate events; wait for gathering before opening the session or add candidate forwarding.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/voice-mode/controller.ts around line 297:

The server receives an SDP offer without the ICE candidates gathered after `setLocalDescription`, so voice connections time out on normal NATed or remote networks. `openSession` is called immediately with `offer.sdp`, and this controller neither waits for ICE gathering to complete nor forwards `icecandidate` events; wait for gathering before opening the session or add candidate forwarding.

interruptionMode: "doNotMix",
shouldPlayInBackground: false,
shouldRouteThroughEarpiece: false,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High voice-mode/voiceMode.ts:75

After releaseVoiceAudio disables Expo's global audio subsystem, every subsequent voice session leaves it disabled, so remote audio does not play. routeVoiceToSpeaker only changes the audio mode and never calls setIsAudioActiveAsync(true); re-enable the subsystem when routing audio for a new session.

  });
+  await setIsAudioActiveAsync(true);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/voice-mode/voiceMode.ts around line 75:

After `releaseVoiceAudio` disables Expo's global audio subsystem, every subsequent voice session leaves it disabled, so remote audio does not play. `routeVoiceToSpeaker` only changes the audio mode and never calls `setIsAudioActiveAsync(true)`; re-enable the subsystem when routing audio for a new session.


useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => {
const command = resolveShortcutCommand(event, keybindings, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/ChatComposer.tsx:5657

The voice shortcut handler treats editableFocus as false, so a binding such as voice.toggleMute with when: "!editableFocus" mutes the microphone while the user is typing in a text editor, while bindings requiring editableFocus never match. Pass the actual editableFocus and the relevant ChatView context flags to resolveShortcutCommand, as the other shortcut paths do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ChatComposer.tsx around line 5657:

The voice shortcut handler treats `editableFocus` as `false`, so a binding such as `voice.toggleMute` with `when: "!editableFocus"` mutes the microphone while the user is typing in a text editor, while bindings requiring `editableFocus` never match. Pass the actual `editableFocus` and the relevant ChatView context flags to `resolveShortcutCommand`, as the other shortcut paths do.

const MAX_SPEAKABLE_CHARS = 990 * 4;

function unescapeXmlText(text: string): string {
return text.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/CodexVoice.ts:28

unescapeXmlText leaves valid " entities in the delegation input, so say "hello" is submitted as literal entity text instead of say "hello". It also leaves doubly escaped &lt;/&gt; as </> because </> are replaced before &; decode & first and include quote entities.

Suggested change
return text.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll(""", '"')
.replaceAll("'", "'");
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/CodexVoice.ts around line 28:

`unescapeXmlText` leaves valid `"` entities in the delegation input, so `say "hello"` is submitted as literal entity text instead of `say "hello"`. It also leaves doubly escaped `&lt;`/`&gt;` as `<`/`>` because `<`/`>` are replaced before `&`; decode `&` first and include quote entities.

@macroscopeapp

macroscopeapp Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds a substantial Codex voice capability across server, web, desktop, and mobile, including WebRTC, microphone permissions, native dependencies, and new provider-session behavior. It also changes the default keybindings and adds static-analysis suppressions, while unresolved findings identify concrete risks in connectivity, permissions, audio lifecycle, and voice handoff handling.

Not approved because:

  • 8 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@juliusmarminge
juliusmarminge deleted the branch pingdotgg:t3code/codex-turn-mapping October 2, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants