| โฆ | โฆ | @@ -5081,6 +5114,27 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | |
| 5121 | + | updated.delete(payload.turn.id); | |
| 5122 | + | return updated; | |
| 5123 | + | }); | |
| 5124 | + | const voiceThread = yield* Ref.get(voiceProviderThread); | |
๐ High Adapters/CodexAdapterV2.ts:5124
A late turn/completed for voice session A is enqueued against the current voiceProviderThread, so if session B starts before A completes, Aโs spoken request is submitted to thread B and can run against Bโs context/files. Store the originating voice thread with each voiceResubmits entry and use that captured thread when offering the continuation.
No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669
Fixed in 17b165e71b. Each voiceResubmits entry now captures the provider thread that was active when Codex started the turn, and the continuation uses that thread, not the current voiceProviderThread.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| โฆ | โฆ | @@ -3825,6 +3837,27 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | |
| 3854 | + | ); | |
| 3855 | + | yield* client | |
| 3856 | + | .request("turn/interrupt", { threadId: payload.threadId, turnId: payload.turnId }) | |
| 3857 | + | .pipe(Effect.ignore, Effect.forkIn(sessionScope)); | |
๐ High Adapters/CodexAdapterV2.ts:3857
A voice handoff can execute twice: if turn/interrupt loses a race with a fast native turn or fails, the completed turn still consumes voiceResubmits and resubmits the same prompt as an ordinary run, duplicating tool and file operations. Do not ignore the interrupt outcome; only resubmit after confirming that the native turn was actually interrupted.
No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669
Fixed in 17b165e71b. The resubmit now runs only when Codex reports the turn interrupted. If the interrupt loses the race or fails, the turn already did the work and its answer is spoken, so nothing runs twice. Covered by the new resubmits a voice handoff Codex started only after its turn is interrupted test, which fails without the check.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| โฆ | โฆ | @@ -2231,6 +2232,53 @@ const makeWsRpcLayer = ( | |
| 2261 | + | `Provider '${runtime.driver}' does not support voice conversations.`, | |
| 2262 | + | ); | |
| 2263 | + | } | |
| 2264 | + | return runtime.startVoiceSession({ providerThread, offerSdp: input.offerSdp }); | |
๐ High src/ws.ts:2264
Starting a replacement voice session drops the new session's provider-thread association, so subsequent delegated voice turns fail the voiceThread !== undefined check and are never resubmitted to Codex. CodexVoice.start closes the previous stream, whose unconditional Stream.ensuring(Ref.set(voiceProviderThread, undefined)) then clears the ref after the replacement has set it; the finalizer must only clear the ref if it still belongs to the stream being finalized.
Also found in 1 other location(s)
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts:5876
Stream.ensuringclears the sharedvoiceProviderThreadunconditionally. When a second client starts voice,voice.startdeliberately closes the previous stream, whose finalizer then sets the ref toundefinedeven though the replacement stream is active. A subsequent delegated voice turn cannot pass thevoiceThread !== undefinedcheck inturn/completed, so its continuation is dropped and the spoken request never runs.
๐ค Copy this AI Prompt to have your agent fix this:
| 1 | In file @apps/server/src/ws.ts around line 2264: |
| 2 | |
| 3 | Starting a replacement voice session drops the new session's provider-thread association, so subsequent delegated voice turns fail the `voiceThread !== undefined` check and are never resubmitted to Codex. `CodexVoice.start` closes the previous stream, whose unconditional `Stream.ensuring(Ref.set(voiceProviderThread, undefined))` then clears the ref after the replacement has set it; the finalizer must only clear the ref if it still belongs to the stream being finalized. |
| 4 | |
| 5 | Also found in 1 other location(s): |
| 6 | - apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts:5876 -- `Stream.ensuring` clears the shared `voiceProviderThread` unconditionally. When a second client starts voice, `voice.start` deliberately closes the previous stream, whose finalizer then sets the ref to `undefined` even though the replacement stream is active. A subsequent delegated voice turn cannot pass the `voiceThread !== undefined` check in `turn/completed`, so its continuation is dropped and the spoken request never runs. |
Fixed in 17b165e71b. A stream's cleanup now clears voiceProviderThread only if it still holds that stream's own provider thread, so a replaced conversation can't clear its successor's thread.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| โฆ | โฆ | @@ -0,0 +1,292 @@ | |
| 257 | + | expectedHandoffs: [], | |
| 258 | + | closed: false, | |
| 259 | + | }; | |
| 260 | + | const previous = yield* Ref.getAndSet(activeRef, active); | |
๐ High Layers/CodexVoice.ts:260
Starting start for thread B terminates the active voice session for thread A, so concurrent voice conversations on different threads cannot work. activeRef stores only one ActiveVoice, and this code unconditionally calls finish(previous) and thread/realtime/stop without checking providerThreadId; track active sessions by thread (and keep finalizers scoped to their own entry) instead.
No longer relevant as of 46603d506211526cc3da2937eed5918ae485c699
Not a bug in this design. makeCodexVoice is built per app-server client, and in V2 each client belongs to one thread's provider session (openSession opens one client per threadId). Two conversations on different threads therefore have separate activeRefs and never replace each other. A second start on the same thread replaces the first on purpose: Codex runs one realtime session per thread, and the newer client takes over.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| โฆ | โฆ | @@ -502,13 +505,38 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | |
| 527 | + | const canToggleVoiceConversation = | |
| 528 | + | voiceConversationLive || | |
| 529 | + | (props.connectionState === "connected" && | |
| 530 | + | props.selectedThread.latestUserMessageAt !== null && | |
๐ High threads/ThreadComposer.tsx:530
The voice button is enabled during initial thread creation, so tapping it starts the microphone flow before providerThread/providerSessionId exists and the server rejects it with "Send a message before starting voice." Because optimistic pending-thread shells already set latestUserMessageAt, that timestamp is not a valid session-readiness check; gate this condition on the threadโs creation/provider-session-ready state instead.
No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669
Fixed in 17b165e71b. The mobile button now waits for activeProviderThreadId, which the server sets once the provider thread exists, instead of latestUserMessageAt, which an optimistic pending thread already has. This matches web's gating.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| โฆ | โฆ | @@ -502,13 +505,38 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | |
| 510 | + | [props.environmentId, props.selectedThread.id], | |
| 511 | + | ); | |
| 512 | + | const voicePhase = useVoiceModePhase(voiceTarget); | |
| 513 | + | useVoiceModeThreadLifecycle(voiceTarget); | |
๐ High threads/ThreadComposer.tsx:513
A live voice session remains active while this mounted composer is hidden, leaving the user unable to stop or mute the recording and continuing the WebRTC session (and billing). useVoiceModeThreadLifecycle only cleans up on unmount or thread changes, but ThreadDetailScreen hides the composer for user-input requests and the settings sheet can cover it without unmounting or changing focus. Make the lifecycle stop or pause the session when the composer is hidden and when the route loses focus, rather than relying only on unmount cleanup.
๐ค Copy this AI Prompt to have your agent fix this:
| 1 | In file @apps/mobile/src/features/threads/ThreadComposer.tsx around line 513: |
| 2 | |
| 3 | A live voice session remains active while this mounted composer is hidden, leaving the user unable to stop or mute the recording and continuing the WebRTC session (and billing). `useVoiceModeThreadLifecycle` only cleans up on unmount or thread changes, but `ThreadDetailScreen` hides the composer for user-input requests and the settings sheet can cover it without unmounting or changing focus. Make the lifecycle stop or pause the session when the composer is hidden and when the route loses focus, rather than relying only on unmount cleanup. |
Fixed in 17b165e71b. ThreadDetailScreen now renders the voice strip (status, mute, stop) above the question card whenever a user-input request hides the composer during a live conversation on that thread.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
The question-card case is covered by 17b165e71b. Screens pushed over the thread (files, review, settings sheet) keep the conversation running on purpose, like a call, and the controls come back with the thread, so we are not stopping on blur; b1231abc19 updates the hook comment to say so.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| โฆ | โฆ | @@ -0,0 +1,117 @@ | |
| 105 | + | } | |
| 106 | + | const activation = keepAwake; | |
| 107 | + | keepAwake = null; | |
| 108 | + | activation?.then(() => deactivateKeepAwake(KEEP_AWAKE_TAG)).catch(() => undefined); | |
๐ High voice-mode/voiceMode.ts:108
The old conversation can deactivate the new conversation's wake lock, allowing the device to auto-lock and terminate an active voice session. Because every conversation uses the same "voice-mode" tag, the cleanup continuation at line 108 cannot distinguish an old activation from the current one; guard deactivation against a newer keepAwake activation (or use a unique per-conversation tag).
No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669
Fixed in 17b165e71b. The deferred cleanup now releases the shared voice-mode tag only when no newer conversation has taken the wake lock (keepAwake === null at release time).
Sorry, I'm unable to act on this request because you do not have permissions within this repository.