fix(google): keep unspoken model text out of the realtime transcript - #2294
Open
anzemur wants to merge 2 commits into
Open
fix(google): keep unspoken model text out of the realtime transcript#2294anzemur wants to merge 2 commits into
anzemur wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: 53905cb The changes in this PR will be included in the next version bump. This PR includes changesets to release 39 packages
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
In a Gemini realtime session running with audio output and output transcription enabled (the plugin default),
RealtimeSession.handleServerContentno longer forwardsmodelTurn.parts[].textinto the generation's text stream. OnlyoutputTranscription— the transcription of the audio the model actually speaks — feeds the transcript.Behaviour is unchanged when the session runs in text modality (
modalities: [Modality.TEXT]) or when output transcription is disabled (outputAudioTranscription: null): there the model's text parts are the response and still flow through.Why
Both writers currently land in the same
gen.textChannel:That channel is the message's
textStream, so whatever goes in ends up in thelk.transcriptionstreams, in the assistantChatMessage(ConversationItemAdded), and in the plugin's own_chatCtxreplay on reconnect.In an audio session a text part on the model turn is never spoken. We hit this in production on
gemini-3.1-flash-live-preview(audio modality,outputAudioTranscription: {}, nothinkingConfig): the model occasionally writes out a function call as text instead of emitting atoolCall— the transcript then contains things likefollowed, in the same generation, by the words that were actually spoken. In one turn the text was cut off exactly where the real
toolCallarrived (handleToolCall→markCurrentGenerationDonecloses the channel), so the partial call was persisted as its own assistant message; in another the whole call came through as text, notoolCallfired, and the leaked text was the only trace. Nothing was said aloud — the leak was invisible in the audio and very visible in captions and persisted history.The same failure class is acknowledged by Google for Gemini 3.x (function calls verbalized into the text stream in
call:<name>{…}/<call:default_api:…/>shape): https://discuss.ai.google.dev/t/gemini-3-5-flash-lite-verbalizes-its-function-call-as-pseudo-xml-in-the-text-stream-instead-of-emitting-a-functioncall-part/176699 — and reported against the Python plugin in livekit/agents#5662. The plugin already skipspart.thoughtfor the same reason (reasoning is not spoken); an unflagged text part in an audio-with-transcription session is the remaining way unspoken text reaches the transcript.Approach
One condition computed once per server message, next to the existing
discardOutput:and the text-part branch becomes
if (part.text && forwardModelText).inlineDatahandling,outputTranscription, tool calls,_firstTokenTimestampand the turn-complete bookkeeping are untouched. Text-modality sessions and audio sessions without transcription behave exactly as before.The Python plugin has the identical merge (
realtime_api.py,_handle_server_content:current_gen.push_text(part.text)andcurrent_gen.push_text(output_transcription.text)), so the same one-line gate applies there if you want parity — happy to open that too.Tests
Wire replay —
plugins/google/src/realtime/realtime_transcript.test.tsdrives the realRealtimeSessionthrough a mockedlive.connect()(same harness aslive_setup_wiring.test.ts), plays server frames in, and asserts on the public output streams (messageStream→textStream/audioStream,functionStream) the wayAgentActivityreads them:maincall:assetGenerator{context:→ audio →outputTranscription: 'Tako je!'→ complete'call:assetGenerator{context:Tako je!', 1 audio frame'Tako je!', 1 audio framecall:getWeather{location:→toolCall getWeather'call:getWeather{location:', tool call delivered'', tool call deliveredmodalities: [TEXT], text part'Hello there.''Hello there.'outputAudioTranscription: null, text part'Hello there.''Hello there.'The first row is the production artifact byte for byte (leaked call glued to the spoken words in one assistant message).
Unit —
Google Realtime model text partsinrealtime_api.test.tscovers the same three configurations at thehandleServerContentlevel (Object.create(RealtimeSession.prototype)pattern already used in that file); the audio+transcription case is red onmain.Live probe (local only, not committed) — real
gemini-3.1-flash-live-previewthrough the patched plugin: a spoken reply arrives asmodelTurn.inlineData(8 frames) +outputTranscription('smoke ok'), zero model text parts, and the plugin's transcript equals the transcription; a tool-calling prompt yields the call onfunctionStream. So the frame shapes the replay test assumes are the ones the API sends, and speech/transcription/tool calls still flow.Checks:
pnpm exec vitest run plugins/google— 45 passed / 2 skipped (pre-existing);tsc --noEmitfor the plugin — clean;eslint— no new findings (three pre-existingno-explicit-anywarnings elsewhere in the file);prettier --check— clean; changeset added (@livekit/agents-plugin-google: patch).🤖 Generated with Claude Code