Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,10 @@ async def _recv_task(self, session: AsyncSession) -> None:
)

if response.server_content:
self._handle_server_content(response.server_content)
self._handle_server_content(
response.server_content,
defer_completion=response.tool_call is not None,

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.

The behaviour is right. Maybe a cleaner shape: finalize here instead of in the two handlers. Both _handle_server_content and _handle_tool_calls are only called from this loop, so the loop can own the decision and the defer_completion flag goes away.

if response.server_content:
    self._handle_server_content(response.server_content)
if response.tool_call:
    self._handle_tool_calls(response.tool_call)
if ((sc := response.server_content) and sc.turn_complete) or response.tool_call:
    self._mark_current_generation_done()

Then drop the turn_complete finalize at the end of _handle_server_content and the one at the end of _handle_tool_calls. Same order as now: content, then the calls, then close, in one iteration.

)
if response.tool_call:
self._handle_tool_calls(response.tool_call)
if response.tool_call_cancellation:
Expand Down Expand Up @@ -1290,7 +1293,12 @@ def _start_new_generation(self) -> None:

self.emit("generation_created", generation_event)

def _handle_server_content(self, server_content: types.LiveServerContent) -> None:
def _handle_server_content(
self,
server_content: types.LiveServerContent,
*,
defer_completion: bool = False,
) -> None:
current_gen = self._current_generation
if not current_gen:
if self._rejected_tool_calls:
Expand Down Expand Up @@ -1379,7 +1387,9 @@ def _handle_server_content(self, server_content: types.LiveServerContent) -> Non
# interrupt agent if there is no pending user initiated generation
self._handle_input_speech_started()

if server_content.turn_complete:
# Gemini may combine turn_complete with a tool call in one message. Keep the
# function channel open until the tool call handler has delivered the call.
if server_content.turn_complete and not defer_completion:
self._mark_current_generation_done()

def _mark_current_generation_done(self) -> None:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_plugin_google_realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,28 @@ async def test_tool_call_is_delivered_without_written_call_text(
assert await _drain_generation(generations[0]) == ("", 0, ["getWeather"])


async def test_tool_call_with_turn_complete_is_delivered_before_generation_closes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A combined terminal content/tool-call response must not close the call channel early."""
async with _make_session(monkeypatch) as session:
generations: list[llm.GenerationCreatedEvent] = []
session.on("generation_created", generations.append)
session._start_new_generation()

# _recv_task defers completion for server content when the same response also
# contains a tool call. Reproduce that dispatch order here so the tool call can
# still be delivered through the open channel.
session._handle_server_content(
types.LiveServerContent(turn_complete=True),
defer_completion=True,
)
session._handle_tool_calls(_tool_call(call_id="fc-combined"))

assert len(generations) == 1
assert await _drain_generation(generations[0]) == ("", 0, ["lookup"])


async def test_transcript_keeps_model_text_in_text_modality(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down