Skip to content

fix(openai realtime): treat create_response=false as client-side turn taking - #2205

Open
rosetta-livekit-bot[bot] wants to merge 3 commits into
enshrine-avionics-denudesfrom
bypasses-blushers-castor
Open

fix(openai realtime): treat create_response=false as client-side turn taking#2205
rosetta-livekit-bot[bot] wants to merge 3 commits into
enshrine-avionics-denudesfrom
bypasses-blushers-castor

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Warning

No CI has run on this PR. gh api repos/livekit/agents-js/commits/67cc6957/check-runs returns total_count: 0. Nothing has built, type-checked, linted, tested, or license-checked this code. Devin Review and license/cla are external apps, not GitHub Actions, so their green marks do not indicate any Actions workflow ran.

The cause is that this PR targets the feature branch of #2158 rather than main, and the Test/Build workflows only trigger on pull_request into next, main, or 0.x. Retargeting is deliberately out of scope while #2158 is open; CI is restored once #2158 lands and this PR is retargeted. See the CI blocker comment on this PR for detail — note that comment cites evidence at head e16f1dad, two commits stale, though its substance still holds at the current head.

Known follow-on: plugins/openai/src/tts.test.ts currently fails to collect when OPENAI_API_KEY is set (pre-existing on the base, see Validation), so the first real CI run on this stack is expected to be red for that unrelated reason.

Ports livekit/agents#6642 to the JS SDK. OpenAI server VAD can still commit and transcribe audio while create_response: false leaves response generation and interruption policy to the client.

This also warns when interrupt_response remains enabled, suppresses the expected empty-buffer commit race under server VAD, and keeps uninterruptible speech intact for client-side turn taking.

Source diff coverage
  • livekit-agents/livekit/agents/voice/agent_activity.py: ported with one documented divergence to agents/src/voice/agent_activity.ts; gate the unexpected uninterruptible-speech error on resolved server-side turn detection. Divergence: Python narrows to except RuntimeError:, so any other exception propagates. JS catches everything and discriminates on the error message string (agents/src/voice/agent_activity.ts:1345-1366), logging a non-matching error at :1357 where Python would let it escape. Two target constraints force this: SpeechHandle.interrupt() signals an uninterruptible handle with a plain Error, so the message is the only discriminator available without a new error subclass; and rethrowing is unsafe because the Google and Phonic realtime plugins emit input_speech_started from call sites that do not all guard against a throwing listener, so an escaping error would abort unrelated session handling. A dedicated error subclass is the tracked follow-up.
  • livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/realtime/realtime_model.py: ported to plugins/openai/src/realtime/realtime_model.ts; resolve create_response: false as client-side turn taking, warn when interrupt_response remains enabled, and suppress the server-VAD empty-buffer commit race.
  • livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/realtime/utils.py: adapted in plugins/openai/src/realtime/realtime_model.ts; JS already accepts and retains the native TurnDetectionType object, including interrupt_response, so no conversion helper exists or is needed.
  • tests/test_realtime/test_openai_realtime_model.py: ported to plugins/openai/src/realtime/realtime_model.test.ts; ported the capability, warning, and empty-commit error tests. The legacy Python SDK conversion test is not applicable because JS has no deprecated Python session.TurnDetection conversion layer and already preserves the field directly.
  • tests/test_realtime_input_speech_interrupt.py: adapted to agents/src/voice/realtime_input_speech_interrupt.test.ts; ported all four behavioral cases using the JS activity/session harness, accounting for JS existing warning-based configuration validation rather than Python constructor rejection.

Validation

  • pnpm exec vitest run agents (113 files, 1528 passed, 5 skipped)
  • env -u OPENAI_API_KEY pnpm test plugins/openai (77 passed, 7 skipped). The env -u prefix is load-bearing and not cosmetic: with OPENAI_API_KEY present, plugins/openai/src/tts.test.ts fails to collect with A VAD instance is required for gpt-realtime-whisper. That failure is pre-existing and unrelated to this PR — it reproduces on the base dfe73afe, and this PR does not touch that file — but the suite is not green without unsetting the key, so the plain command will fail for a reviewer who has one set.
  • pnpm --filter @livekit/agents build
  • pnpm --filter @livekit/agents-plugin-openai build
  • pnpm --filter @livekit/agents lint
  • pnpm --filter @livekit/agents-plugin-openai lint
  • pnpm build
  • cue-cli text-mode runtime validation: client-triggered greeting and user-turn reply emitted as framework conversation events with no error event

Ported from livekit/agents#6642

Original PR description

Server VAD with create_response=False still commits and transcribes the user audio, but the reply is the client's call, so the session no longer reports server-side turn detection and allow_interruptions=False is honored.

Fixes livekit/agents#6635

Cue voice E2E

Verified exact head 67cc695746bd87ea651afbb20fe32b00b4e621de against dfe73afed63ce4fa71850bdd12642d50ee1b38e3 in voice mode with the real OpenAI Realtime API and Silero VAD. No framework source was modified for the run.

Note on the baseline. Because this PR targets a feature branch rather than main (see the CI blocker comment on this PR), the comparand is the merge base on that branch, dfe73afe — not origin/main.

The scenario is client-side turn taking. Under turnDetection: { create_response: false } the user speaks and the question is simply whether the assistant ever answers. One script and one predicate across all four runs:

{ conversation_item_added(.item.message.role="USER")
  and conversation_item_added(.item.message.role="ASSISTANT") }[75s]

The 75s bound is roughly 21x the head arm's observed 3.1s user-to-reply latency, so a baseline timeout reflects a reply that never comes rather than impatience.

  • Head arm, 67cc6957 (cue-2205-head): sessions sid_5d9272a7b1ed and sid_68fc7159dab1, both resolved in 7.9s and 8.2s. Decisive event in sid_5d9272a7b1ed at offset 31 (monotonic_ms 12316): conversation_item_added role ASSISTANT, "The capital city of France is Paris." The second head session committed "Hello! The capital city of France is Paris." — the greeting differs run to run; what matters is that an ASSISTANT item exists in both.
  • Baseline arm, dfe73afe (cue-2205-base): sessions sid_0a281ef6669a and sid_8ad16c0eb94b, both timed out at 70.5s and 70.3s. No ASSISTANT conversation_item_added exists at any offset in either session; the user is left waiting until user_state_changed -> US_AWAY, at offset 23 in sid_0a281ef6669a (monotonic_ms 24240; the second baseline session reaches the same state at 24111).

Shared precondition. The baseline arm proves its point by an absence, so it matters that it got as far as head did. Both arms committed a USER conversation_item_added carrying the byte-identical transcript "Hello there, please tell me what the capital city of France is." — at 9211/9316 ms on head and 9242/9114 ms on baseline. Baseline heard the user, transcribed the turn, committed it, and then stood down without replying, which is precisely the reported bug.

Arm attribution. Each arm ran under a distinct agentName, and each session is self-identifying: a build_identity event reads the witness off the loaded code under a create_response: false config. Head reports capabilitiesTurnDetection: false, rtTurnDetectionEnabled: false; baseline reports true, true. That value is exactly what this PR changes, so a session cannot be attributed to the wrong arm by mislabeling. Each event also carries its gitSha, worktree, and resolved agents/dist/index.cjs and openai plugin paths.

Limitations.

  1. Two runs per arm.
  2. Only the reply-presence change is exercised end-to-end. The input_audio_buffer_commit_empty suppression and the create_response/interrupt_response config warning never fired in these runs, and the narrowed error path was not triggered.
  3. The realtime input-speech interrupt behavior changed in agents/src/voice/agent_activity.ts is covered by the new unit tests in realtime_input_speech_interrupt.test.ts, not by this run. This evidence should not be read as whole-PR E2E coverage.
  4. One config shape only.

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner August 2, 2026 08:39
@changeset-bot

changeset-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 67cc695

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from longcw August 2, 2026 08:40

@devin-ai-integration devin-ai-integration Bot 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

toubatbrian and others added 2 commits August 3, 2026 14:12
…arted

The rtTurnDetectionEnabled gate sat on a catch-all, so with client-side turn
taking every exception from interrupt() was discarded with no log or trace --
in exactly the mode the gate exists to enable. Only the uninterruptible-speech
error is expected there; anything else is now logged unconditionally.

Python narrows via `except RuntimeError`. JS cannot rethrow the equivalent:
realtime plugins emit input_speech_started from call sites that do not all
guard against a throwing listener, so an escaping error would abort unrelated
session handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
…n detection

Drop the unreachable `turnDetection !== undefined` clause from the empty-commit
gate so it is the single condition Python uses. Both the model constructor and
the session constructor always assign a concrete value, so the clause could
never be false.

Add the case neither language covered: a model built with server_vad whose
session sets turnDetectionDisabled must still report input_audio_buffer_commit_empty.
That is the path that makes the suppression gate correct rather than over-broad.

Co-authored-by: Cursor <cursoragent@cursor.com>
@toubatbrian

Copy link
Copy Markdown
Contributor

⚠️ Merge blocker: no CI has run on this PR

This PR targets enshrine-avionics-denudes (the head of #2158) rather than main. The GitHub Actions workflows gate on pull_request: branches: [next, main, 0.x] (.github/workflows/build.yml:6-10, .github/workflows/test.yml:9-13), so Build, Test, Formatting and REUSE-3.2 were never queued — not skipped, not pending, not cancelled.

Evidence at head e16f1dad:

gh api repos/livekit/agents-js/commits/e16f1dad.../check-runs  → total_count: 0
gh run list --branch bypasses-blushers-castor                  → (empty)
gh api repos/livekit/agents-js/branches/enshrine-avionics-denudes → {"protected": false}
gh api repos/livekit/agents-js/branches/main                      → {"protected": true}

The only two green signals (Devin Review, license/cla) are commit statuses, which post regardless of base branch — not Actions check runs.

Because the base branch is unprotected, main's required-checks and required-review rules do not apply, so this PR reports mergeStateStatus: CLEAN / MERGEABLE with an empty reviewDecision while being completely untested by CI. Compare sibling #2204, which targets main and correctly reports BLOCKED / REVIEW_REQUIRED.

The stacking itself is legitimate and necessary

This is not a mistargeting error. The diff reads this.rtTurnDetectionEnabled (agents/src/voice/agent_activity.ts), which does not exist on main:

git show origin/main:agents/src/voice/agent_activity.ts | rg rtTurnDetectionEnabled  → no matches
git show dfe73afe:agents/src/voice/agent_activity.ts    | rg rtTurnDetectionEnabled  → 20 matches

rtTurnDetectionEnabled and resolveRealtimeTurnDetectionEnabled() are introduced by #2158. Retargeting this PR to main today would not compile.

Requested sequence before merge

  1. Do not merge while this targets enshrine-avionics-denudes. Land feat(voice): auto-disable realtime server-side turn detection #2158 into main first.
  2. Retarget this PR to main, which re-triggers all four Actions checks and restores branch protection.
  3. Confirm all checks green at the retargeted head, then merge.

Heads-up: a pre-existing test failure on the base branch

plugins/openai/src/tts.test.ts currently fails to collect on enshrine-avionics-denudes — it constructs an STT for gpt-realtime-whisper without a VAD. This is untouched by this PR and predates it, but it will turn CI red as soon as the stack reaches main. Worth fixing on #2158 or separately before step 2.

Interim local verification at head e16f1dad

Because CI could not run, the equivalent checks were run locally in a clean worktree at the exact head:

  • vitest run agents/src/voice/realtime_input_speech_interrupt.test.ts plugins/openai/src/realtime/realtime_model.test.ts → 2 files, 55 tests passed
  • tsc --noEmit for both agents/tsconfig.json and plugins/openai/tsconfig.json → clean
  • prettier --check on all changed files → clean
  • REUSE: REUSE.toml line 18 already covers .changeset/**; the new test file carries its own SPDX header

This mitigates but does not substitute for CI — it does not cover the full suites, the build step, or throws:check.

Repo-level suggestion, independent of this PR

Any PR retargeted off main silently loses all four Actions checks while still reporting green. Consider either widening the pull_request branch filter to cover stack branches, or adding a required "CI ran" gate, so an untested PR cannot present as CLEAN.


This PR is deliberately not being labelled verified-port until CI has actually run against it.

@toubatbrian

Copy link
Copy Markdown
Contributor

verified-port withheld — reason

Parity verification for this port is complete and positive: the Python comparison against e3d1a28b5 is faithful, the one divergence (catch breadth in agent_activity.ts) is now documented in the description with its rationale, and the client-turn-taking behavior is verified end-to-end with real OpenAI Realtime audio — head replies to the user, the base dfe73afe commits the user's turn and never replies, across two runs per arm with self-identifying build attribution.

The label is nonetheless withheld for one reason: no CI has run on this PR at all. check-runs at head 67cc6957 returns total_count: 0, because the PR targets #2158's feature branch rather than main. That is not a weak signal, it is the absence of the strongest automated gate — nothing has compiled, type-checked, linted, or license-checked this code. verified-port asserts a verification standard, and applying it here would weaken its meaning on the ports that do carry full CI.

This is curable and expected to clear on its own: once #2158 lands and this PR is retargeted to main, CI runs and the label can be applied without any further code change. Note the pre-existing plugins/openai/src/tts.test.ts collection failure documented in the description will need handling at that point, as it is unrelated to this PR but will show red.

Everything else on this PR is gated: fixes applied and independently re-reviewed clean, zero unresolved review threads, changeset present, and the description corrected for accuracy against the persisted Cue artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant