|
17 | 17 | from __future__ import annotations |
18 | 18 |
|
19 | 19 | import asyncio |
| 20 | +import contextlib |
20 | 21 | import logging |
| 22 | +from typing import AsyncGenerator |
21 | 23 | from unittest import mock |
22 | 24 |
|
23 | 25 | from google.adk.agents.invocation_context import InvocationContext |
|
33 | 35 | from google.adk.live.live_request_queue import LiveRequestQueue |
34 | 36 | from google.adk.models.llm_request import LlmRequest |
35 | 37 | from google.adk.models.llm_response import LlmResponse |
| 38 | +from google.adk.runners import Runner |
36 | 39 | from google.adk.sessions.in_memory_session_service import InMemorySessionService |
37 | 40 | from google.adk.sessions.session import Session |
38 | 41 | from google.adk.tools.set_model_response_tool import SetModelResponseTool |
| 42 | +from google.genai import errors |
39 | 43 | from google.genai import types |
40 | 44 | from pydantic import BaseModel |
41 | 45 | import pytest |
| 46 | +from websockets.exceptions import ConnectionClosed |
42 | 47 |
|
43 | 48 | from .. import testing_utils |
44 | 49 |
|
@@ -547,6 +552,177 @@ async def _receive(): |
547 | 552 | ] |
548 | 553 |
|
549 | 554 |
|
| 555 | +@pytest.mark.parametrize( |
| 556 | + 'update_without_handle', |
| 557 | + [ |
| 558 | + types.LiveServerSessionResumptionUpdate(resumable=False), |
| 559 | + types.LiveServerSessionResumptionUpdate( |
| 560 | + last_consumed_client_message_index=7 |
| 561 | + ), |
| 562 | + ], |
| 563 | + ids=['not_resumable', 'consumed_index_only'], |
| 564 | +) |
| 565 | +async def test_receive_from_model_keeps_the_last_handle_the_server_offered( |
| 566 | + update_without_handle: types.LiveServerSessionResumptionUpdate, |
| 567 | +) -> None: |
| 568 | + """An update that carries no handle does not erase the one held. |
| 569 | +
|
| 570 | + While the model is generating or a tool call is in flight, the server reports |
| 571 | + the session as not resumable with an update that has no handle. The handle a |
| 572 | + reconnect resumes with must stay the latest one the server actually offered. |
| 573 | + """ |
| 574 | + flow = _TestBaseLlmFlow() |
| 575 | + context = _create_test_context() |
| 576 | + calls = 0 |
| 577 | + |
| 578 | + async def _receive() -> AsyncGenerator[LlmResponse, None]: |
| 579 | + nonlocal calls |
| 580 | + calls += 1 |
| 581 | + if calls == 1: |
| 582 | + for update in ( |
| 583 | + types.LiveServerSessionResumptionUpdate( |
| 584 | + new_handle='handle-1', resumable=True |
| 585 | + ), |
| 586 | + update_without_handle, |
| 587 | + types.LiveServerSessionResumptionUpdate( |
| 588 | + new_handle='handle-2', resumable=True |
| 589 | + ), |
| 590 | + update_without_handle, |
| 591 | + ): |
| 592 | + yield LlmResponse(live_session_resumption_update=update) |
| 593 | + |
| 594 | + connection = mock.MagicMock(receive=_receive) |
| 595 | + |
| 596 | + async for _ in _live_llm_flow.receive_from_model( |
| 597 | + flow, connection, context, LlmRequest() |
| 598 | + ): |
| 599 | + pass |
| 600 | + |
| 601 | + assert context.live_session_resumption_handle == 'handle-2' |
| 602 | + |
| 603 | + |
| 604 | +@pytest.mark.parametrize( |
| 605 | + 'interruption', |
| 606 | + [ |
| 607 | + LlmResponse(go_away=types.LiveServerGoAway(time_left='10s')), |
| 608 | + errors.APIError(1006, {}), |
| 609 | + errors.APIError(1011, {}), |
| 610 | + ConnectionClosed(None, None), |
| 611 | + ], |
| 612 | + ids=['go_away', 'abnormal_closure', 'internal_error', 'connection_closed'], |
| 613 | +) |
| 614 | +async def test_run_live_resumes_a_connection_lost_while_not_resumable( |
| 615 | + interruption: LlmResponse | Exception, |
| 616 | +) -> None: |
| 617 | + """A connection lost while the model is answering still resumes the session. |
| 618 | +
|
| 619 | + Setup: the server offers a handle, then reports the session as not resumable |
| 620 | + because the model has started answering, and then the connection goes: a |
| 621 | + GoAway, or a connection that drops. |
| 622 | + Act: run the live session through `Runner.run_live`. |
| 623 | + Assert: the run reconnects with the handle the server offered, and does not |
| 624 | + replay the conversation history that the resumed session already holds. |
| 625 | + """ |
| 626 | + |
| 627 | + class _ScriptedConnection(testing_utils.MockLlmConnection): |
| 628 | + """Plays one connection's script once, raising the exceptions in it.""" |
| 629 | + |
| 630 | + def __init__(self, script: list[LlmResponse | Exception]): |
| 631 | + super().__init__(llm_responses=[]) |
| 632 | + self.script = script |
| 633 | + self.history: list[types.Content] | None = None |
| 634 | + |
| 635 | + async def send_history(self, history: list[types.Content]) -> None: |
| 636 | + self.history = history |
| 637 | + |
| 638 | + async def receive(self) -> AsyncGenerator[LlmResponse, None]: |
| 639 | + while self.script: |
| 640 | + item = self.script.pop(0) |
| 641 | + if isinstance(item, Exception): |
| 642 | + raise item |
| 643 | + yield item |
| 644 | + |
| 645 | + class _ScriptedModel(testing_utils.MockModel): |
| 646 | + """Opens the next scripted connection on each connect.""" |
| 647 | + |
| 648 | + scripts: list[list[LlmResponse | Exception]] |
| 649 | + connections: list[_ScriptedConnection] = [] |
| 650 | + handles: list[str | None] = [] |
| 651 | + |
| 652 | + # MockModel.connect is annotated as returning the connection itself, so |
| 653 | + # mypy reads its yield type as Never and rejects any typed override. |
| 654 | + @contextlib.asynccontextmanager |
| 655 | + async def connect( # type: ignore[override] |
| 656 | + self, llm_request: LlmRequest |
| 657 | + ) -> AsyncGenerator[_ScriptedConnection, None]: |
| 658 | + resumption = llm_request.live_connect_config.session_resumption |
| 659 | + self.handles.append(resumption.handle if resumption else None) |
| 660 | + connection = _ScriptedConnection(self.scripts[len(self.connections)]) |
| 661 | + self.connections.append(connection) |
| 662 | + yield connection |
| 663 | + |
| 664 | + def _resumption_update( |
| 665 | + *, new_handle: str | None = None, resumable: bool |
| 666 | + ) -> LlmResponse: |
| 667 | + return LlmResponse( |
| 668 | + live_session_resumption_update=types.LiveServerSessionResumptionUpdate( |
| 669 | + new_handle=new_handle, resumable=resumable |
| 670 | + ) |
| 671 | + ) |
| 672 | + |
| 673 | + model = _ScriptedModel( |
| 674 | + responses=[], |
| 675 | + scripts=[ |
| 676 | + [ |
| 677 | + _resumption_update(new_handle='handle-1', resumable=True), |
| 678 | + _resumption_update(resumable=False), |
| 679 | + interruption, |
| 680 | + ], |
| 681 | + # The resumed connection has nothing more to send, which ends the |
| 682 | + # run. |
| 683 | + [], |
| 684 | + ], |
| 685 | + ) |
| 686 | + session_service = InMemorySessionService() |
| 687 | + session = await session_service.create_session(app_name='app', user_id='u') |
| 688 | + # An earlier turn, which a session started from scratch replays. |
| 689 | + await session_service.append_event( |
| 690 | + session, |
| 691 | + Event( |
| 692 | + invocation_id='earlier', |
| 693 | + author='user', |
| 694 | + content=types.UserContent('Book a table for two.'), |
| 695 | + ), |
| 696 | + ) |
| 697 | + runner = Runner( |
| 698 | + app_name='app', |
| 699 | + agent=LlmAgent(name='root_agent', model=model), |
| 700 | + session_service=session_service, |
| 701 | + ) |
| 702 | + |
| 703 | + async def _run_to_end() -> None: |
| 704 | + async with contextlib.aclosing( |
| 705 | + runner.run_live( |
| 706 | + user_id='u', |
| 707 | + session_id=session.id, |
| 708 | + live_request_queue=LiveRequestQueue(), |
| 709 | + run_config=RunConfig( |
| 710 | + session_resumption=types.SessionResumptionConfig() |
| 711 | + ), |
| 712 | + ) |
| 713 | + ) as events: |
| 714 | + async for _ in events: |
| 715 | + pass |
| 716 | + |
| 717 | + await asyncio.wait_for(_run_to_end(), timeout=5) |
| 718 | + |
| 719 | + assert model.handles == [None, 'handle-1'] |
| 720 | + # The first connection started the session and replayed the earlier turn; |
| 721 | + # the resumed one must not replay it into the session that already has it. |
| 722 | + assert model.connections[0].history |
| 723 | + assert model.connections[1].history is None |
| 724 | + |
| 725 | + |
550 | 726 | async def test_turn_complete_saves_image_frames_sent_to_the_model(): |
551 | 727 | """Camera frames sent during a turn are saved as one archive at its end.""" |
552 | 728 | flow = _TestBaseLlmFlow() |
|
0 commit comments