feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: add persistent OpenAI Realtime voice panel - #3997

Closed
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel
Closed

feat: add persistent OpenAI Realtime voice panel#3997
Winds-AI wants to merge 9 commits into
pingdotgg:mainfrom
Winsoser:agent/grok-voice-panel

Conversation

@Winds-AI

@Winds-AIWinds-AI commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • replace the Grok voice layer with OpenAI Realtime over WebRTC, with gpt-realtime-2.1-mini as the default and gpt-realtime-2.1 as the stronger selectable model
  • mint short-lived OpenAI Realtime client secrets on the T3 server so the long-lived API key never enters the renderer
  • use WebRTC-native audio, interruption, playback truncation, and echo handling instead of the custom raw-PCM WebSocket pipeline
  • add documented Realtime controls for voice, 0.25x-1.5x speech speed, input-language hint, reasoning effort, semantic-VAD eagerness, and microphone noise profile
  • keep one global voice session active across task navigation and application switches
  • seed only the latest completed assistant message, with paginated access to earlier messages from the origin task
  • retain safe composer read/replacement tools and add a Pi-inspired atomic exact-edit tool; voice can draft but never send prompts
  • retain Parallel Search and Extract as deterministic custom web tools whose results must return before OpenAI continues
  • migrate voice settings and trace storage away from the xAI schema, remove old xAI voice history, and best-effort delete the old stored xAI voice key
  • preserve the separate T3 Code Voice macOS identity and add the correct OpenAI microphone permission text

Realtime behavior

  • the server creates the ephemeral session with automatic responses disabled, then the renderer installs the researched prompt, tools, and user settings before listening begins
  • the prompt uses labeled role, tone, reasoning, verbosity, tool, unclear-audio, task-context, and composer-editing sections from OpenAI's Realtime prompting guidance
  • semantic VAD supports patient, balanced, and quick turn-taking while WebRTC handles interruptions and truncates unplayed model audio
  • tool calls clear queued output as a guard, and stale tool completions cannot create a response after a user interruption
  • search_web waits for Parallel Search; extract_web_pages is available for selected URLs when ranked excerpts are insufficient

Security and migration

  • OpenAI and Parallel long-lived keys remain in the selected T3 server secret store
  • only the short-lived OpenAI client secret reaches the renderer
  • renderer microphone permission remains audio-only and limited to the trusted T3 renderer origin
  • composer exact edits require unique matches and are applied atomically with an expected-current-text check
  • task context is treated as untrusted conversation data, not as instructions
  • the unrelated T3 Grok harness/provider remains available; this PR removes only the xAI voice integration

Validation

  • vp check passes with 9 pre-existing React warnings in unrelated files
  • vp run typecheck passes
  • full server suite: 1,403 passed, 7 skipped
  • full web suite: 1,292 passed
  • desktop artifact tests: 27 passed
  • production desktop build, arm64 DMG, and ZIP completed successfully
  • installed /Applications/T3 Code Voice.app is ad-hoc signed and passes strict deep signature verification
  • Computer Use verified the OpenAI key UI, Parallel tools, model/voice/speed/language/reasoning/VAD/noise settings, global-session copy, and empty migrated history
  • a live OpenAI audio/tool session remains to be tested after an OpenAI API key is entered in Voice settings

References

@coderabbitai

coderabbitaiBot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a6dc5b5-5927-4d51-a8a9-9dd28a64aa33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 15, 2026
scheduler: configScheduler,
concurrency: configConcurrency,
}),
setVoiceCredential: createEnvironmentRpcCommand(runtime, {

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.

🟡 Mediumstate/server.ts:346

setVoiceCredential and removeVoiceCredential pass configConcurrency (serial mode keyed by environmentId) but omit the shared configScheduler. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass configScheduler to both commands, as updateSettings and the other config mutation commands do.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 346:
`setVoiceCredential` and `removeVoiceCredential` pass `configConcurrency` (serial mode keyed by `environmentId`) but omit the shared `configScheduler`. Because each command creates its own scheduler, the serial key does not serialize the two commands against each other — a rapid set-then-remove sequence can execute both RPCs concurrently, so the final stored credential depends on completion order rather than invocation order. Pass `configScheduler` to both commands, as `updateSettings` and the other config mutation commands do.

setAssistantTranscript("");
}, []);

const start = useCallback(() => {

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.

🟠 Highvoice/VoiceSession.tsx:355

If start() is called again after end() but before the pending createVoiceSession resolves, the first call's accessResult sees activeRef.current === true (set by the second call) and proceeds to create its own socket and VoiceAudioController, overwriting socketRef and audioRef with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's close handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around line 355:
If `start()` is called again after `end()` but before the pending `createVoiceSession` resolves, the first call's `accessResult` sees `activeRef.current === true` (set by the second call) and proceeds to create its own socket and `VoiceAudioController`, overwriting `socketRef` and `audioRef` with the stale session's instances. The second session's socket and audio controller are then orphaned — the socket stays open and the microphone keeps capturing audio — while either session's `close` handler can deactivate the other. Use a per-start generation token and discard results, socket handlers, and audio teardown that don't match the current generation.

Comment threadapps/desktop/scripts/electron-launcher.mjs
Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +54 to +56
useEffect(() => {
if (queriedStatus) setConfigured(queriedStatus.configured);
}, [queriedStatus]);

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.

🟡 Mediumsettings/VoiceSettingsPanel.tsx:54

When environmentId changes, configured keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The useEffect only sets configured when queriedStatus is non-null, so a loading or failed query leaves the previous value in place. Consider resetting configured to false whenever environmentId changes, or deriving the controls directly from queriedStatus instead of mirroring it in local state.

- useEffect(() => {- if (queriedStatus) setConfigured(queriedStatus.configured);- }, [queriedStatus]);+ useEffect(() => {+ setConfigured(queriedStatus?.configured ?? false);+ }, [queriedStatus]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/VoiceSettingsPanel.tsx around lines 54-56:
When `environmentId` changes, `configured` keeps the old environment's value until the new environment's status finishes loading, so the Test and Remove buttons stay enabled for the new environment before its credential status is known. Clicking Remove deletes the new environment's credential based on stale state from a different environment. The `useEffect` only sets `configured` when `queriedStatus` is non-null, so a loading or failed query leaves the previous value in place. Consider resetting `configured` to `false` whenever `environmentId` changes, or deriving the controls directly from `queriedStatus` instead of mirroring it in local state.

),
}));
},
clearHistory: () => set({ sessions: [], activeSessionId: 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.

🟡 Mediumvoice/voiceTraceStore.ts:153

clearHistory deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active traceSessionIdRef without ending the session. Every subsequent appendEntry and completeSession call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

Suggested change
clearHistory: ()=>set({sessions: [],activeSessionId: null}),
clearHistory: ()=>
set((state)=>({
sessions: state.activeSessionId
? state.sessions.filter((s)=>s.id===state.activeSessionId)
: [],
activeSessionId: state.activeSessionId,
})),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 153:
`clearHistory` deletes the currently active session along with completed history. While a voice session is live, the settings page exposes this action, and clearing it discards the active `traceSessionIdRef` without ending the session. Every subsequent `appendEntry` and `completeSession` call silently finds no matching session, so the live timeline and the final trace are lost. Preserve the active session (or end/reset it consistently) when clearing history.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions

One convention issue found in the new voice service error model: VoiceApiError cannot carry an underlying cause, so every wrapping site discards the real failure and its stack. This diverges from the repo-wide convention (SecretStoreError, and the filesystem/project/vcs/assets contracts) where service errors preserve the immediate failure as cause: Schema.Defect() and derive stable messages from structured attributes.

Posted via Macroscope — Effect Service Conventions

Comment on lines +29 to +32
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError has no cause field, so every translation boundary in VoiceSessionService (secretStoreFailure, the HTTP request/response mapErrors, and the schemaBodyJson decode mapError in createSession) throws away the underlying SecretStoreError / HTTP / decode failure and its stack. The convention is to preserve the immediate underlying error as cause alongside the structural fields, matching the rest of the contracts package (e.g. SecretStoreError, filesystem.ts, project.ts).

Add an optional cause (optional because the pure credential_invalid empty-key validation has no underlying failure):

Suggested change
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
}){}
exportclassVoiceApiErrorextendsSchema.TaggedErrorClass<VoiceApiError>()("VoiceApiError",{
reason: VoiceApiErrorReason,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}){}

Posted via Macroscope — Effect Service Conventions

Comment on lines +34 to +39
function secretStoreFailure(): VoiceApiError {
return new VoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}

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.

This mapper drops the underlying SecretStoreError when translating to VoiceApiError, losing the error chain. Once VoiceApiError accepts a cause, forward the incoming error (the existing Effect.mapError(secretStoreFailure) call sites already pass it):

Suggested change
functionsecretStoreFailure(): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
});
}
functionsecretStoreFailure(cause: unknown): VoiceApiError{
returnnewVoiceApiError({
reason: "secret_store_failed",
message: "T3 Code could not access the saved xAI voice credential.",
cause,
});
}

Apply the same cause forwarding to the upstream_unavailable / credential_invalid mappers in createSession.

Posted via Macroscope — Effect Service Conventions

Comment on lines +372 to +375
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
for (const call of calls) {

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.

🟡 Mediumvoice/VoiceSession.tsx:372

If the WebSocket closes during the 50 ms tool-call batching window, flushToolCalls bails early because the socket isn't open but never clears toolQueueRef.current. The socket close handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in flushToolCalls before the socket check, and in end and the socket close handler.

 const flushToolCalls = useCallback(() => {
toolTimerRef.current = null;
- const socket = socketRef.current;- if (!socket || socket.readyState !== WebSocket.OPEN) return;
const calls = toolQueueRef.current.splice(0);
+ const socket = socketRef.current;+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/VoiceSession.tsx around lines 372-375:
If the WebSocket closes during the 50 ms tool-call batching window, `flushToolCalls` bails early because the socket isn't open but never clears `toolQueueRef.current`. The socket `close` handler also doesn't drain the queue. When a new session starts on a fresh socket, the stale queued calls from the previous session are flushed and executed against the current composer, and their outputs are sent on the new socket. Clear the queue in `flushToolCalls` before the socket check, and in `end` and the socket `close` handler.

Comment threadapps/web/src/components/voice/VoiceAudioProcessor.worklet.ts Outdated
Comment on lines +55 to +56
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,

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.

🟡 Mediumvoice/VoiceSessionService.ts:55

ParallelExtractResponse defines http_status_code as Schema.Number and content as Schema.String, but the Parallel /v1/extract API returns null for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into web_tool_unavailable, discarding all successful results. Both fields should accept null (e.g. Schema.NullOr(Schema.Number) and Schema.NullOr(Schema.String)), and the content formatting at line 391 should handle null.

Suggested change
http_status_code: Schema.optionalKey(Schema.Number),
content: Schema.String,
http_status_code: Schema.optionalKey(Schema.NullOr(Schema.Number)),
content: Schema.NullOr(Schema.String),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/voice/VoiceSessionService.ts around lines 55-56:
`ParallelExtractResponse` defines `http_status_code` as `Schema.Number` and `content` as `Schema.String`, but the Parallel `/v1/extract` API returns `null` for these fields when no HTTP status or body is available. A successful extract response containing such a per-URL error fails schema validation and is converted into `web_tool_unavailable`, discarding all successful results. Both fields should accept `null` (e.g. `Schema.NullOr(Schema.Number)` and `Schema.NullOr(Schema.String)`), and the `content` formatting at line 391 should handle `null`.

@Winds-AIWinds-AI changed the title feat: add persistent Grok voice panelfeat: add persistent OpenAI Realtime voice panelJul 16, 2026
Comment on lines +199 to +201
storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),

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.

🟡 Mediumvoice/voiceSettingsStore.ts:199

The storage factory passes window.localStorage directly into resolveStorage, so when the localStorage property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing useVoiceSettingsStore throws and can prevent the web UI from loading. resolveStorage only provides its memory fallback when it receives undefined, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in try/catch (or a safe accessor) so the factory returns undefined on failure.

+ storage: createJSONStorage(() => {+ try {+ return resolveStorage(typeof window === "undefined" ? undefined : window.localStorage);+ } catch {+ return resolveStorage(undefined);+ }+ }),
Also found in 1 other location(s)

apps/web/src/components/voice/voiceTraceStore.ts:203

The storage factory evaluates window.localStorage before resolveStorage can fall back to memory storage. If the browser exposes window but throws when the localStorage property is accessed (for example in a hardened storage context), initialization of useVoiceTraceStore throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceSettingsStore.ts around lines 199-201:
The storage factory passes `window.localStorage` directly into `resolveStorage`, so when the `localStorage` property access itself throws (hardened browsers / unavailable storage — the case this file explicitly intends to support), initializing `useVoiceSettingsStore` throws and can prevent the web UI from loading. `resolveStorage` only provides its memory fallback when it receives `undefined`, but it never gets that chance because the exception happens during argument evaluation. Wrap the property access in `try/catch` (or a safe accessor) so the factory returns `undefined` on failure.
Also found in 1 other location(s):
- apps/web/src/components/voice/voiceTraceStore.ts:203 -- The storage factory evaluates `window.localStorage` before `resolveStorage` can fall back to memory storage. If the browser exposes `window` but throws when the `localStorage` property is accessed (for example in a hardened storage context), initialization of `useVoiceTraceStore` throws and can prevent the voice UI from loading. The property access itself needs to be guarded.

storage: createJSONStorage(() =>
resolveStorage(typeof window === "undefined" ? undefined : window.localStorage),
),
partialize: (state) => ({ sessions: state.sessions }),

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.

🟡 Mediumvoice/voiceTraceStore.ts:205

After a page reload, persisted sessions with status: "active" remain stuck in that state forever. The partialize option persists sessions but not activeSessionId, and nothing on hydration finalizes sessions left active by a reload or crash, so completeSession is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as error (or completed) during rehydration, for example in an onRehydrateStorage callback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/voice/voiceTraceStore.ts around line 205:
After a page reload, persisted sessions with `status: "active"` remain stuck in that state forever. The `partialize` option persists sessions but not `activeSessionId`, and nothing on hydration finalizes sessions left active by a reload or crash, so `completeSession` is never called for them. They display a green active indicator indefinitely even though no voice connection exists. Consider marking previously-active sessions as `error` (or `completed`) during rehydration, for example in an `onRehydrateStorage` callback.

@macroscopeappmacroscopeappBot left a comment

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.

Effect Service Conventions review: one error-modeling convention violation in the new voice contract/service code. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +84 to +87
export class VoiceApiError extends Schema.TaggedErrorClass<VoiceApiError>()("VoiceApiError", {
reason: VoiceApiErrorReason,
message: Schema.String,
}) {}

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.

VoiceApiError stores a free-form message: Schema.String as its payload. Every other tagged error in this package derives message from structured attributes via an override get message() getter (e.g. SecretStore*Error, GitCommandError, ExternalLauncher*Error), so the user-facing string is a stable function of the error shape rather than hand-written at each new VoiceApiError({ reason, message }) call site.

Since reason already carries the distinction and each reason feeds a distinct message, derive the message from reason (plus any safe structural context) in a getter instead of storing it. If some reasons need genuinely different caller control flow or messages, split those into separate error classes, per the convention on modeling distinct failures.

Posted via Macroscope — Effect Service Conventions

@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Duplicates the maintainer-owned voice implementation in #5213.

@t3dotggt3dotgg closed this Aug 23, 2026
NeilTheFisher added a commit to NeilTheFisher/t3code that referenced this pull request Sep 1, 2026
Squashed from PR pingdotgg#3997 (9 commits; adds then replaces the Grok voice
layer with OpenAI Realtime over WebRTC, gpt-realtime-2.1-mini default).
- Server: authenticated voice.* RPCs (session, credential, Parallel web
tools) implemented by VoiceSessionService with server-side key storage
and short-lived OpenAI client secrets.
- Web: persistent VoiceSession panel + trace timeline, composer tools
(voice can draft but never send), /settings/voice settings page.
- Desktop: 'T3 Code Voice' packaged variant with isolated state dir.
Fork merge notes: adopted fork's rewrites (settingsSearch-driven nav,
DesktopStatePaths helpers, single RPC_REQUIRED_SCOPES map with voice
scopes registered there instead of the PR's dead duplicate map).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Winds-AI@t3dotgg@meetl-e2m