chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,538 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the resumption flow with different agent structures."""
|
||||
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.agents.llm_agent import LlmAgent
|
||||
from google.adk.agents.loop_agent import LoopAgent
|
||||
from google.adk.agents.parallel_agent import ParallelAgent
|
||||
from google.adk.agents.sequential_agent import SequentialAgent
|
||||
from google.adk.apps.app import App
|
||||
from google.adk.apps.app import ResumabilityConfig
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.tools.exit_loop_tool import exit_loop
|
||||
from google.adk.tools.function_tool import FunctionTool
|
||||
from google.adk.tools.long_running_tool import LongRunningFunctionTool
|
||||
from google.genai.types import Part
|
||||
import pytest
|
||||
|
||||
from .. import testing_utils
|
||||
|
||||
|
||||
def _transfer_call_part(agent_name: str) -> Part:
|
||||
return Part.from_function_call(
|
||||
name="transfer_to_agent", args={"agent_name": agent_name}
|
||||
)
|
||||
|
||||
|
||||
def test_tool():
|
||||
"""A test tool; returns None to simulate a pending long-running operation."""
|
||||
return None
|
||||
|
||||
|
||||
class _TestingAgent(BaseAgent):
|
||||
"""A testing agent that generates an event after a delay."""
|
||||
|
||||
delay: float = 0
|
||||
"""The delay before the agent generates an event."""
|
||||
|
||||
def event(self, ctx: InvocationContext):
|
||||
return Event(
|
||||
author=self.name,
|
||||
branch=ctx.branch,
|
||||
invocation_id=ctx.invocation_id,
|
||||
content=testing_utils.ModelContent(
|
||||
parts=[Part.from_text(text="Delayed message")]
|
||||
),
|
||||
)
|
||||
|
||||
async def _run_async_impl(
|
||||
self, ctx: InvocationContext
|
||||
) -> AsyncGenerator[Event, None]:
|
||||
await asyncio.sleep(self.delay)
|
||||
yield self.event(ctx)
|
||||
|
||||
|
||||
_TRANSFER_RESPONSE_PART = Part.from_function_response(
|
||||
name="transfer_to_agent", response={"result": None}
|
||||
)
|
||||
END_OF_AGENT = testing_utils.END_OF_AGENT
|
||||
|
||||
|
||||
class BasePauseInvocationTest:
|
||||
"""Base class for pausing invocation tests with common fixtures."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> BaseAgent:
|
||||
"""Provides a BaseAgent for the test."""
|
||||
return BaseAgent(name="test_agent")
|
||||
|
||||
@pytest.fixture
|
||||
def app(self, agent: BaseAgent) -> App:
|
||||
"""Provides an App for the test."""
|
||||
return App(
|
||||
name="test_app",
|
||||
root_agent=agent,
|
||||
resumability_config=ResumabilityConfig(is_resumable=True),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self, app: App) -> testing_utils.InMemoryRunner:
|
||||
"""Provides an in-memory runner for the agent."""
|
||||
return testing_utils.InMemoryRunner(app=app)
|
||||
|
||||
@staticmethod
|
||||
def mock_model(responses: list[Part]) -> testing_utils.MockModel:
|
||||
"""Provides a mock model with predefined responses."""
|
||||
return testing_utils.MockModel.create(responses=responses)
|
||||
|
||||
|
||||
class TestPauseInvocationWithSingleLlmAgent(BasePauseInvocationTest):
|
||||
"""Tests the resumption flow with a single LlmAgent."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> BaseAgent:
|
||||
"""Provides a BaseAgent for the test."""
|
||||
return LlmAgent(
|
||||
name="root_agent",
|
||||
model=self.mock_model(
|
||||
responses=[Part.from_function_call(name="test_tool", args={})]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_on_long_running_function_call(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
):
|
||||
"""Tests that a single LlmAgent pauses on long running function call."""
|
||||
actual = testing_utils.simplify_resumable_app_events(runner.run("test"))
|
||||
behavioral = [e for e in actual if not isinstance(e[1], dict)]
|
||||
assert behavioral == [
|
||||
# execute_tools yields the interrupt event with long_running_tool_ids.
|
||||
("root_agent", Part.from_function_call(name="test_tool", args={})),
|
||||
]
|
||||
|
||||
|
||||
class TestPauseInvocationWithSequentialAgent(BasePauseInvocationTest):
|
||||
"""Tests pausing invocation with a SequentialAgent."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> BaseAgent:
|
||||
"""Provides a BaseAgent for the test."""
|
||||
sub_agent1 = LlmAgent(
|
||||
name="sub_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[Part.from_function_call(name="test_tool", args={})]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
sub_agent2 = LlmAgent(
|
||||
name="sub_agent_2",
|
||||
model=self.mock_model(
|
||||
responses=[Part.from_function_call(name="test_tool", args={})]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
return SequentialAgent(
|
||||
name="root_agent",
|
||||
sub_agents=[sub_agent1, sub_agent2],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_first_agent_on_long_running_function_call(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
):
|
||||
"""Tests that a SequentialAgent pauses on the first sub-agent."""
|
||||
actual = testing_utils.simplify_resumable_app_events(runner.run("test"))
|
||||
behavioral = [e for e in actual if not isinstance(e[1], dict)]
|
||||
assert behavioral == [
|
||||
("sub_agent_1", Part.from_function_call(name="test_tool", args={})),
|
||||
]
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason=(
|
||||
"Tests implementation details that are different in V2 and will be"
|
||||
" deprecated."
|
||||
)
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_second_agent_on_long_running_function_call(
|
||||
self,
|
||||
):
|
||||
"""Tests that a single LlmAgent pauses on long running function call."""
|
||||
# Construct sub_agent_1 with regular FunctionTool (not long-running).
|
||||
sub_agent_1 = LlmAgent(
|
||||
name="sub_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
Part.from_text(text="model response after tool call"),
|
||||
]
|
||||
),
|
||||
tools=[FunctionTool(func=test_tool)],
|
||||
)
|
||||
sub_agent_2 = LlmAgent(
|
||||
name="sub_agent_2",
|
||||
model=self.mock_model(
|
||||
responses=[Part.from_function_call(name="test_tool", args={})]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
agent = SequentialAgent(
|
||||
name="root_agent",
|
||||
sub_agents=[sub_agent_1, sub_agent_2],
|
||||
)
|
||||
app = App(
|
||||
name="test_app",
|
||||
root_agent=agent,
|
||||
resumability_config=ResumabilityConfig(is_resumable=True),
|
||||
)
|
||||
runner = testing_utils.InMemoryRunner(app=app)
|
||||
actual = testing_utils.simplify_resumable_app_events(runner.run("test"))
|
||||
behavioral = [e for e in actual if not isinstance(e[1], dict)]
|
||||
assert behavioral == [
|
||||
("sub_agent_1", Part.from_function_call(name="test_tool", args={})),
|
||||
(
|
||||
"sub_agent_1",
|
||||
Part.from_function_response(
|
||||
name="test_tool", response={"result": None}
|
||||
),
|
||||
),
|
||||
("sub_agent_1", "model response after tool call"),
|
||||
# Wrapper emits output before END_OF_AGENT.
|
||||
("root_agent", "model response after tool call"),
|
||||
("sub_agent_1", END_OF_AGENT),
|
||||
("sub_agent_2", Part.from_function_call(name="test_tool", args={})),
|
||||
]
|
||||
|
||||
|
||||
class TestPauseInvocationWithParallelAgent(BasePauseInvocationTest):
|
||||
"""Tests pausing invocation with a ParallelAgent."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> BaseAgent:
|
||||
"""Provides a BaseAgent for the test."""
|
||||
sub_agent1 = LlmAgent(
|
||||
name="sub_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[Part.from_function_call(name="test_tool", args={})]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
sub_agent2 = _TestingAgent(
|
||||
name="sub_agent_2",
|
||||
delay=0.5,
|
||||
)
|
||||
return ParallelAgent(
|
||||
name="root_agent",
|
||||
sub_agents=[sub_agent1, sub_agent2],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_on_long_running_function_call(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
):
|
||||
"""Tests that a ParallelAgent pauses on long running function call."""
|
||||
simplified_event_parts = testing_utils.simplify_resumable_app_events(
|
||||
runner.run("test")
|
||||
)
|
||||
assert (
|
||||
"sub_agent_1",
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
) in simplified_event_parts
|
||||
assert ("sub_agent_2", "Delayed message") in simplified_event_parts
|
||||
|
||||
|
||||
class TestPauseInvocationWithNestedParallelAgent(BasePauseInvocationTest):
|
||||
"""Tests pausing invocation with a nested ParallelAgent."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> BaseAgent:
|
||||
"""Provides a BaseAgent for the test."""
|
||||
nested_sub_agent_1 = LlmAgent(
|
||||
name="nested_sub_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[Part.from_function_call(name="test_tool", args={})]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
nested_sub_agent_2 = _TestingAgent(
|
||||
name="nested_sub_agent_2",
|
||||
delay=0.5,
|
||||
)
|
||||
nested_parallel_agent = ParallelAgent(
|
||||
name="nested_parallel_agent",
|
||||
sub_agents=[nested_sub_agent_1, nested_sub_agent_2],
|
||||
)
|
||||
sub_agent_1 = _TestingAgent(
|
||||
name="sub_agent_1",
|
||||
delay=0.5,
|
||||
)
|
||||
return ParallelAgent(
|
||||
name="root_agent",
|
||||
sub_agents=[sub_agent_1, nested_parallel_agent],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_on_long_running_function_call(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
):
|
||||
"""Tests that a nested ParallelAgent pauses on long running function call."""
|
||||
simplified_event_parts = testing_utils.simplify_resumable_app_events(
|
||||
runner.run("test")
|
||||
)
|
||||
assert (
|
||||
"nested_sub_agent_1",
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
) in simplified_event_parts
|
||||
assert ("sub_agent_1", "Delayed message") in simplified_event_parts
|
||||
assert ("nested_sub_agent_2", "Delayed message") in simplified_event_parts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_on_multiple_long_running_function_calls(
|
||||
self,
|
||||
):
|
||||
"""Tests that a ParallelAgent pauses on long running function calls."""
|
||||
nested_sub_agent_1 = LlmAgent(
|
||||
name="nested_sub_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[Part.from_function_call(name="test_tool", args={})]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
nested_sub_agent_2 = _TestingAgent(
|
||||
name="nested_sub_agent_2",
|
||||
delay=0.5,
|
||||
)
|
||||
nested_parallel_agent = ParallelAgent(
|
||||
name="nested_parallel_agent",
|
||||
sub_agents=[nested_sub_agent_1, nested_sub_agent_2],
|
||||
)
|
||||
sub_agent_1 = LlmAgent(
|
||||
name="sub_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
agent = ParallelAgent(
|
||||
name="root_agent",
|
||||
sub_agents=[sub_agent_1, nested_parallel_agent],
|
||||
)
|
||||
app = App(
|
||||
name="test_app",
|
||||
root_agent=agent,
|
||||
resumability_config=ResumabilityConfig(is_resumable=True),
|
||||
)
|
||||
runner = testing_utils.InMemoryRunner(app=app)
|
||||
simplified_events = testing_utils.simplify_resumable_app_events(
|
||||
runner.run("test")
|
||||
)
|
||||
assert (
|
||||
"sub_agent_1",
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
) in simplified_events
|
||||
assert ("sub_agent_1", END_OF_AGENT) not in simplified_events
|
||||
assert (
|
||||
"nested_sub_agent_1",
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
) in simplified_events
|
||||
assert ("nested_sub_agent_1", END_OF_AGENT) not in simplified_events
|
||||
|
||||
|
||||
class TestPauseInvocationWithLoopAgent(BasePauseInvocationTest):
|
||||
"""Tests pausing invocation with a LoopAgent."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> BaseAgent:
|
||||
"""Provides a BaseAgent for the test."""
|
||||
sub_agent_1 = LlmAgent(
|
||||
name="sub_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
Part.from_text(text="sub agent 1 response"),
|
||||
]
|
||||
),
|
||||
)
|
||||
sub_agent_2 = LlmAgent(
|
||||
name="sub_agent_2",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
sub_agent_3 = LlmAgent(
|
||||
name="sub_agent_3",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
Part.from_function_call(name="exit_loop", args={}),
|
||||
]
|
||||
),
|
||||
tools=[exit_loop],
|
||||
)
|
||||
return LoopAgent(
|
||||
name="root_agent",
|
||||
sub_agents=[sub_agent_1, sub_agent_2, sub_agent_3],
|
||||
max_iterations=2,
|
||||
)
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason=(
|
||||
"Tests implementation details that are different in V2 and will be"
|
||||
" deprecated."
|
||||
)
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_on_long_running_function_call(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
):
|
||||
"""Tests that a LoopAgent pauses on long running function call."""
|
||||
actual = testing_utils.simplify_resumable_app_events(runner.run("test"))
|
||||
behavioral = [e for e in actual if not isinstance(e[1], dict)]
|
||||
assert behavioral == [
|
||||
("sub_agent_1", "sub agent 1 response"),
|
||||
# Wrapper emits output before END_OF_AGENT.
|
||||
("root_agent", "sub agent 1 response"),
|
||||
("sub_agent_1", END_OF_AGENT),
|
||||
("sub_agent_2", Part.from_function_call(name="test_tool", args={})),
|
||||
]
|
||||
|
||||
|
||||
class TestPauseInvocationWithLlmAgentTree(BasePauseInvocationTest):
|
||||
"""Tests the pausing invocation with a tree of LlmAgents."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> LlmAgent:
|
||||
"""Provides an LlmAgent with sub-agents for the test."""
|
||||
sub_llm_agent_1 = LlmAgent(
|
||||
name="sub_llm_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
_transfer_call_part("sub_llm_agent_2"),
|
||||
"llm response not used",
|
||||
]
|
||||
),
|
||||
)
|
||||
sub_llm_agent_2 = LlmAgent(
|
||||
name="sub_llm_agent_2",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
"llm response not used",
|
||||
]
|
||||
),
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
return LlmAgent(
|
||||
name="root_agent",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
_transfer_call_part("sub_llm_agent_1"),
|
||||
"llm response not used",
|
||||
]
|
||||
),
|
||||
sub_agents=[sub_llm_agent_1, sub_llm_agent_2],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_on_long_running_function_call(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
):
|
||||
"""Tests that a tree of resumable LlmAgents yields checkpoint events."""
|
||||
actual = testing_utils.simplify_resumable_app_events(runner.run("test"))
|
||||
behavioral = [e for e in actual if not isinstance(e[1], dict)]
|
||||
assert behavioral == [
|
||||
("root_agent", _transfer_call_part("sub_llm_agent_1")),
|
||||
("root_agent", _TRANSFER_RESPONSE_PART),
|
||||
("root_agent", END_OF_AGENT),
|
||||
("sub_llm_agent_1", _transfer_call_part("sub_llm_agent_2")),
|
||||
("sub_llm_agent_1", _TRANSFER_RESPONSE_PART),
|
||||
("sub_llm_agent_1", END_OF_AGENT),
|
||||
("sub_llm_agent_2", Part.from_function_call(name="test_tool", args={})),
|
||||
]
|
||||
|
||||
|
||||
class TestPauseInvocationWithWithTransferLoop(BasePauseInvocationTest):
|
||||
"""Tests pausing the invocation when the agent transfer forms a loop."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self) -> LlmAgent:
|
||||
"""Provides an LlmAgent with sub-agents for the test."""
|
||||
sub_llm_agent_1 = LlmAgent(
|
||||
name="sub_llm_agent_1",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
_transfer_call_part("sub_llm_agent_2"),
|
||||
"llm response not used",
|
||||
]
|
||||
),
|
||||
)
|
||||
sub_llm_agent_2 = LlmAgent(
|
||||
name="sub_llm_agent_2",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
_transfer_call_part("root_agent"),
|
||||
"llm response not used",
|
||||
]
|
||||
),
|
||||
)
|
||||
return LlmAgent(
|
||||
name="root_agent",
|
||||
model=self.mock_model(
|
||||
responses=[
|
||||
_transfer_call_part("sub_llm_agent_1"),
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
"llm response not used",
|
||||
]
|
||||
),
|
||||
sub_agents=[sub_llm_agent_1, sub_llm_agent_2],
|
||||
tools=[LongRunningFunctionTool(func=test_tool)],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_pause_on_long_running_function_call(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
):
|
||||
"""Tests that a tree of resumable LlmAgents yields checkpoint events."""
|
||||
actual = testing_utils.simplify_resumable_app_events(runner.run("test"))
|
||||
behavioral = [e for e in actual if not isinstance(e[1], dict)]
|
||||
assert behavioral == [
|
||||
("root_agent", _transfer_call_part("sub_llm_agent_1")),
|
||||
("root_agent", _TRANSFER_RESPONSE_PART),
|
||||
("root_agent", END_OF_AGENT),
|
||||
("sub_llm_agent_1", _transfer_call_part("sub_llm_agent_2")),
|
||||
("sub_llm_agent_1", _TRANSFER_RESPONSE_PART),
|
||||
("sub_llm_agent_1", END_OF_AGENT),
|
||||
("sub_llm_agent_2", _transfer_call_part("root_agent")),
|
||||
("sub_llm_agent_2", _TRANSFER_RESPONSE_PART),
|
||||
("sub_llm_agent_2", END_OF_AGENT),
|
||||
("root_agent", Part.from_function_call(name="test_tool", args={})),
|
||||
]
|
||||
@@ -0,0 +1,317 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Tests for edge cases of resuming invocations."""
|
||||
|
||||
import copy
|
||||
|
||||
from google.adk.agents.llm_agent import LlmAgent
|
||||
from google.adk.apps.app import App
|
||||
from google.adk.apps.app import ResumabilityConfig
|
||||
from google.adk.tools.long_running_tool import LongRunningFunctionTool
|
||||
from google.genai.types import FunctionResponse
|
||||
from google.genai.types import Part
|
||||
import pytest
|
||||
|
||||
from .. import testing_utils
|
||||
|
||||
|
||||
def transfer_call_part(agent_name: str) -> Part:
|
||||
return Part.from_function_call(
|
||||
name="transfer_to_agent", args={"agent_name": agent_name}
|
||||
)
|
||||
|
||||
|
||||
TRANSFER_RESPONSE_PART = Part.from_function_response(
|
||||
name="transfer_to_agent", response={"result": None}
|
||||
)
|
||||
|
||||
|
||||
def test_tool():
|
||||
"""A test tool; returns None to simulate a pending long-running operation."""
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason=(
|
||||
"Tests implementation details that are different in V2 and will be"
|
||||
" deprecated."
|
||||
)
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_invocation_from_sub_agent():
|
||||
"""A test case for an edge case, where an invocation-to-resume starts from a sub-agent.
|
||||
|
||||
For example:
|
||||
invocation1: root_agent -> sub_agent (sub_agent completes normally)
|
||||
invocation2: sub_agent calls long_running_tool -> pauses
|
||||
resume invocation2: sub_agent gets function response -> responds
|
||||
"""
|
||||
# Step 1: Setup
|
||||
long_running_test_tool = LongRunningFunctionTool(func=test_tool)
|
||||
sub_agent = LlmAgent(
|
||||
name="sub_agent",
|
||||
model=testing_utils.MockModel.create(
|
||||
responses=[
|
||||
"first response from sub_agent",
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
"response from sub_agent after resume",
|
||||
]
|
||||
),
|
||||
tools=[long_running_test_tool],
|
||||
)
|
||||
root_agent = LlmAgent(
|
||||
name="root_agent",
|
||||
model=testing_utils.MockModel.create(
|
||||
responses=[transfer_call_part(sub_agent.name)]
|
||||
),
|
||||
sub_agents=[sub_agent],
|
||||
)
|
||||
runner = testing_utils.InMemoryRunner(
|
||||
app=App(
|
||||
name="test_app",
|
||||
root_agent=root_agent,
|
||||
resumability_config=ResumabilityConfig(is_resumable=True),
|
||||
)
|
||||
)
|
||||
|
||||
# Step 2: Run the first invocation
|
||||
# root_agent transfers to sub_agent, sub_agent responds normally.
|
||||
invocation_1_events = await runner.run_async("test user query")
|
||||
inv1_behavioral = [
|
||||
e
|
||||
for e in testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(invocation_1_events)
|
||||
)
|
||||
if not isinstance(e[1], dict)
|
||||
]
|
||||
assert inv1_behavioral == [
|
||||
(
|
||||
root_agent.name,
|
||||
transfer_call_part(sub_agent.name),
|
||||
),
|
||||
(
|
||||
root_agent.name,
|
||||
TRANSFER_RESPONSE_PART,
|
||||
),
|
||||
(
|
||||
root_agent.name,
|
||||
testing_utils.END_OF_AGENT,
|
||||
),
|
||||
(
|
||||
sub_agent.name,
|
||||
"first response from sub_agent",
|
||||
),
|
||||
(
|
||||
sub_agent.name,
|
||||
testing_utils.END_OF_AGENT,
|
||||
),
|
||||
]
|
||||
|
||||
# Step 3: Run the second invocation
|
||||
# sub_agent is now active. It calls long_running_tool, which pauses.
|
||||
invocation_2_events = await runner.run_async("test user query 2")
|
||||
inv2_behavioral = [
|
||||
e
|
||||
for e in testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(invocation_2_events)
|
||||
)
|
||||
if not isinstance(e[1], dict)
|
||||
]
|
||||
assert inv2_behavioral == [
|
||||
# execute_tools yields the interrupt event with long_running_tool_ids.
|
||||
(
|
||||
sub_agent.name,
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
),
|
||||
]
|
||||
|
||||
# Find the function_call_id for resume.
|
||||
invocation_2_function_call_id = None
|
||||
for ev in invocation_2_events:
|
||||
if (
|
||||
ev.content
|
||||
and ev.content.parts
|
||||
and ev.content.parts[0].function_call
|
||||
and ev.content.parts[0].function_call.name == "test_tool"
|
||||
):
|
||||
invocation_2_function_call_id = ev.content.parts[0].function_call.id
|
||||
break
|
||||
assert invocation_2_function_call_id is not None
|
||||
|
||||
# Step 4: Resume the second invocation with function response.
|
||||
resumed_invocation_2_events = await runner.run_async(
|
||||
invocation_id=invocation_2_events[0].invocation_id,
|
||||
new_message=testing_utils.UserContent(
|
||||
Part(
|
||||
function_response=FunctionResponse(
|
||||
id=invocation_2_function_call_id,
|
||||
name="test_tool",
|
||||
response={"result": "test tool update"},
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
resumed_inv2_behavioral = [
|
||||
e
|
||||
for e in testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(resumed_invocation_2_events)
|
||||
)
|
||||
if not isinstance(e[1], dict)
|
||||
]
|
||||
assert resumed_inv2_behavioral == [
|
||||
# execute_tools yields the function response from resume.
|
||||
(
|
||||
sub_agent.name,
|
||||
Part.from_function_response(
|
||||
name="test_tool",
|
||||
response={"result": "test tool update"},
|
||||
),
|
||||
),
|
||||
(
|
||||
sub_agent.name,
|
||||
"response from sub_agent after resume",
|
||||
),
|
||||
(sub_agent.name, testing_utils.END_OF_AGENT),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"Cross-invocation resume (resuming a non-latest invocation) is not"
|
||||
" supported by the Mesh-based LlmAgent. The Mesh's output aggregation"
|
||||
" in node_output_utils.py collects events from multiple invocations,"
|
||||
" causing CallLlmResult to be wrapped in a list."
|
||||
)
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_any_invocation():
|
||||
"""A test case for resuming a previous invocation instead of the last one."""
|
||||
# Step 1: Setup
|
||||
long_running_test_tool = LongRunningFunctionTool(
|
||||
func=test_tool,
|
||||
)
|
||||
root_agent = LlmAgent(
|
||||
name="root_agent",
|
||||
model=testing_utils.MockModel.create(
|
||||
responses=[
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
"llm response in invocation 2",
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
"llm response after resuming invocation 1",
|
||||
]
|
||||
),
|
||||
tools=[long_running_test_tool],
|
||||
)
|
||||
runner = testing_utils.InMemoryRunner(
|
||||
app=App(
|
||||
name="test_app",
|
||||
root_agent=root_agent,
|
||||
resumability_config=ResumabilityConfig(is_resumable=True),
|
||||
)
|
||||
)
|
||||
|
||||
# Step 2: Run the first invocation, which pauses on the long running function.
|
||||
invocation_1_events = await runner.run_async("test user query")
|
||||
inv1_behavioral = [
|
||||
e
|
||||
for e in testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(invocation_1_events)
|
||||
)
|
||||
if not isinstance(e[1], dict)
|
||||
]
|
||||
assert inv1_behavioral == [
|
||||
(
|
||||
root_agent.name,
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
),
|
||||
]
|
||||
|
||||
# Find the function_call_id for resume.
|
||||
invocation_1_function_call_id = None
|
||||
for ev in invocation_1_events:
|
||||
if (
|
||||
ev.content
|
||||
and ev.content.parts
|
||||
and ev.content.parts[0].function_call
|
||||
and ev.content.parts[0].function_call.name == "test_tool"
|
||||
):
|
||||
invocation_1_function_call_id = ev.content.parts[0].function_call.id
|
||||
break
|
||||
assert invocation_1_function_call_id is not None
|
||||
|
||||
# Step 3: Run the second invocation, expect it to finish normally.
|
||||
invocation_2_events = await runner.run_async(
|
||||
"test user query 2",
|
||||
)
|
||||
inv2_behavioral = [
|
||||
e
|
||||
for e in testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(invocation_2_events)
|
||||
)
|
||||
if not isinstance(e[1], dict)
|
||||
]
|
||||
assert inv2_behavioral == [
|
||||
(
|
||||
root_agent.name,
|
||||
"llm response in invocation 2",
|
||||
),
|
||||
(root_agent.name, testing_utils.END_OF_AGENT),
|
||||
]
|
||||
|
||||
# Step 4: Run the third invocation, which also pauses on the long running
|
||||
# function.
|
||||
invocation_3_events = await runner.run_async(
|
||||
"test user query 3",
|
||||
)
|
||||
inv3_behavioral = [
|
||||
e
|
||||
for e in testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(invocation_3_events)
|
||||
)
|
||||
if not isinstance(e[1], dict)
|
||||
]
|
||||
assert inv3_behavioral == [
|
||||
(
|
||||
root_agent.name,
|
||||
Part.from_function_call(name="test_tool", args={}),
|
||||
),
|
||||
]
|
||||
|
||||
# Step 5: Resume the first invocation with long running function response.
|
||||
resumed_invocation_1_events = await runner.run_async(
|
||||
invocation_id=invocation_1_events[0].invocation_id,
|
||||
new_message=testing_utils.UserContent(
|
||||
Part(
|
||||
function_response=FunctionResponse(
|
||||
id=invocation_1_function_call_id,
|
||||
name="test_tool",
|
||||
response={"result": "test tool update"},
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
resumed_inv1_behavioral = [
|
||||
e
|
||||
for e in testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(resumed_invocation_1_events)
|
||||
)
|
||||
if not isinstance(e[1], dict)
|
||||
]
|
||||
assert resumed_inv1_behavioral == [
|
||||
(
|
||||
root_agent.name,
|
||||
"llm response after resuming invocation 1",
|
||||
),
|
||||
(root_agent.name, testing_utils.END_OF_AGENT),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,917 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for Runner.run_debug helper method."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.agents import Agent
|
||||
from google.adk.agents.run_config import RunConfig
|
||||
from google.adk.runners import InMemoryRunner
|
||||
import pytest
|
||||
|
||||
|
||||
class TestRunDebug:
|
||||
"""Tests for Runner.run_debug method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_single_query(self):
|
||||
"""Test run_debug with a single string query."""
|
||||
# Setup
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="You are a helpful assistant.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
# Mock the runner's run_async to return controlled events
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text="Hello! I can help you.")]
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute
|
||||
events = await runner.run_debug("Hello, how are you?", quiet=True)
|
||||
|
||||
# Assertions
|
||||
assert len(events) == 1
|
||||
assert events[0].author == "test_agent"
|
||||
assert events[0].content.parts[0].text == "Hello! I can help you."
|
||||
|
||||
# Verify session was created with defaults
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name,
|
||||
user_id="debug_user_id",
|
||||
session_id="debug_session_id",
|
||||
)
|
||||
assert session is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_multiple_queries(self):
|
||||
"""Test run_debug with multiple queries in sequence."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="You are a test bot.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
# Mock responses for multiple queries
|
||||
responses = ["First response", "Second response"]
|
||||
call_count = 0
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text=responses[call_count])]
|
||||
call_count += 1
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute with multiple queries
|
||||
events = await runner.run_debug(
|
||||
["First query", "Second query"], quiet=True
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert len(events) == 2
|
||||
assert events[0].content.parts[0].text == "First response"
|
||||
assert events[1].content.parts[0].text == "Second response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_always_returns_events(self):
|
||||
"""Test that run_debug always returns events."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text="Response")]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Test that events are always returned
|
||||
events = await runner.run_debug("Query", quiet=True)
|
||||
assert isinstance(events, list)
|
||||
assert len(events) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_quiet_mode(self, capsys):
|
||||
"""Test that quiet=True suppresses printing."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text="This should not be printed")]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute with quiet=True
|
||||
await runner.run_debug("Test query", quiet=True)
|
||||
|
||||
# Check that nothing was printed to stdout or logged
|
||||
captured = capsys.readouterr()
|
||||
assert "This should not be printed" not in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_custom_session_id(self):
|
||||
"""Test run_debug with custom session_id."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text="Response")]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute with custom session ID
|
||||
await runner.run_debug(
|
||||
"Query", session_id="custom_debug_session", quiet=True
|
||||
)
|
||||
|
||||
# Verify session was created with custom ID
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name,
|
||||
user_id="debug_user_id",
|
||||
session_id="custom_debug_session",
|
||||
)
|
||||
assert session is not None
|
||||
assert session.id == "custom_debug_session"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_custom_user_id(self):
|
||||
"""Test run_debug with custom user_id."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text="Response")]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute with custom user_id
|
||||
await runner.run_debug("Query", user_id="test_user_123", quiet=True)
|
||||
|
||||
# Verify session was created with custom user_id
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name,
|
||||
user_id="test_user_123",
|
||||
session_id="debug_session_id",
|
||||
)
|
||||
assert session is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_run_config(self):
|
||||
"""Test that run_config is properly passed through to run_async."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
run_config_used = None
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
nonlocal run_config_used
|
||||
run_config_used = kwargs.get("run_config")
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text="Response")]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(
|
||||
runner, "run_async", side_effect=mock_run_async
|
||||
) as mock_method:
|
||||
# Create a custom run_config
|
||||
custom_config = RunConfig(support_cfc=True)
|
||||
|
||||
# Execute with custom run_config
|
||||
await runner.run_debug("Query", run_config=custom_config, quiet=True)
|
||||
|
||||
# Verify run_config was passed to run_async
|
||||
assert mock_method.called
|
||||
call_args = mock_method.call_args
|
||||
assert call_args is not None
|
||||
assert "run_config" in call_args.kwargs
|
||||
assert call_args.kwargs["run_config"] == custom_config
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_session_persistence(self):
|
||||
"""Test that multiple calls to run_debug maintain conversation context."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Remember previous messages.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
call_count = 0
|
||||
responses = ["First response", "Second response remembering first"]
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text=responses[call_count])]
|
||||
call_count += 1
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# First call
|
||||
events1 = await runner.run_debug("First message", quiet=True)
|
||||
assert events1[0].content.parts[0].text == "First response"
|
||||
|
||||
# Second call to same session
|
||||
events2 = await runner.run_debug("Second message", quiet=True)
|
||||
assert (
|
||||
events2[0].content.parts[0].text
|
||||
== "Second response remembering first"
|
||||
)
|
||||
|
||||
# Verify both calls used the same session
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name,
|
||||
user_id="debug_user_id",
|
||||
session_id="debug_session_id",
|
||||
)
|
||||
assert session is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_filters_none_text(self):
|
||||
"""Test that run_debug filters out 'None' text and empty parts."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Yield events with various text values
|
||||
events = [
|
||||
mock.Mock(
|
||||
author="test_agent",
|
||||
content=mock.Mock(parts=[mock.Mock(text="Valid text")]),
|
||||
),
|
||||
mock.Mock(
|
||||
author="test_agent",
|
||||
content=mock.Mock(parts=[mock.Mock(text="None")]),
|
||||
), # Should be filtered
|
||||
mock.Mock(
|
||||
author="test_agent",
|
||||
content=mock.Mock(parts=[mock.Mock(text="")]),
|
||||
), # Should be filtered
|
||||
mock.Mock(
|
||||
author="test_agent",
|
||||
content=mock.Mock(parts=[mock.Mock(text="Another valid")]),
|
||||
),
|
||||
]
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute and capture output
|
||||
events = await runner.run_debug("Query", quiet=True)
|
||||
|
||||
# All 4 events should be returned (filtering is for printing only)
|
||||
assert len(events) == 4
|
||||
|
||||
# But when printing, "None" and empty strings should be filtered
|
||||
# This is tested implicitly by the implementation
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_existing_session(self):
|
||||
"""Test that run_debug retrieves existing session when AlreadyExistsError occurs."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
# First create a session
|
||||
await runner.session_service.create_session(
|
||||
app_name=runner.app_name,
|
||||
user_id="debug_user_id",
|
||||
session_id="existing_session",
|
||||
)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [mock.Mock(text="Using existing session")]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute with same session ID (should retrieve existing)
|
||||
events = await runner.run_debug(
|
||||
"Query", session_id="existing_session", quiet=True
|
||||
)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].content.parts[0].text == "Using existing session"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_tool_calls(self, capsys):
|
||||
"""Test that run_debug properly handles and prints tool calls."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with tools.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# First event: tool call
|
||||
mock_call_event = mock.Mock()
|
||||
mock_call_event.author = "test_agent"
|
||||
mock_call_event.content = mock.Mock()
|
||||
mock_function_call = mock.Mock()
|
||||
mock_function_call.name = "calculate"
|
||||
mock_function_call.args = {"operation": "add", "a": 5, "b": 3}
|
||||
mock_part_call = mock.Mock()
|
||||
mock_part_call.text = None
|
||||
mock_part_call.function_call = mock_function_call
|
||||
mock_part_call.function_response = None
|
||||
mock_call_event.content.parts = [mock_part_call]
|
||||
yield mock_call_event
|
||||
|
||||
# Second event: tool response
|
||||
mock_resp_event = mock.Mock()
|
||||
mock_resp_event.author = "test_agent"
|
||||
mock_resp_event.content = mock.Mock()
|
||||
mock_function_response = mock.Mock()
|
||||
mock_function_response.response = {"result": 8}
|
||||
mock_part_resp = mock.Mock()
|
||||
mock_part_resp.text = None
|
||||
mock_part_resp.function_call = None
|
||||
mock_part_resp.function_response = mock_function_response
|
||||
mock_resp_event.content.parts = [mock_part_resp]
|
||||
yield mock_resp_event
|
||||
|
||||
# Third event: final text response
|
||||
mock_text_event = mock.Mock()
|
||||
mock_text_event.author = "test_agent"
|
||||
mock_text_event.content = mock.Mock()
|
||||
mock_text_event.content.parts = [mock.Mock(text="The result is 8")]
|
||||
yield mock_text_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
# Execute with verbose=True to see tool calls
|
||||
events = await runner.run_debug("Calculate 5 + 3", verbose=True)
|
||||
|
||||
# Check output was printed
|
||||
captured = capsys.readouterr()
|
||||
assert "[Calling tool: calculate" in captured.out
|
||||
assert "[Tool result:" in captured.out
|
||||
assert "The result is 8" in captured.out
|
||||
|
||||
# Check events were collected
|
||||
assert len(events) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_executable_code(self, capsys):
|
||||
"""Test that run_debug properly handles executable code parts."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with code execution.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Event with executable code
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
|
||||
mock_exec_code = mock.Mock()
|
||||
mock_exec_code.language = "python"
|
||||
mock_exec_code.code = "print('Hello World')"
|
||||
|
||||
mock_part = mock.Mock()
|
||||
mock_part.text = None
|
||||
mock_part.function_call = None
|
||||
mock_part.function_response = None
|
||||
mock_part.executable_code = mock_exec_code
|
||||
mock_part.code_execution_result = None
|
||||
mock_part.inline_data = None
|
||||
mock_part.file_data = None
|
||||
|
||||
mock_event.content.parts = [mock_part]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug("Run some code", verbose=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[Executing python code...]" in captured.out
|
||||
assert len(events) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_code_execution_result(self, capsys):
|
||||
"""Test that run_debug properly handles code execution result parts."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with code results.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Event with code execution result
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
|
||||
mock_result = mock.Mock()
|
||||
mock_result.output = "Hello World\n42"
|
||||
|
||||
mock_part = mock.Mock()
|
||||
mock_part.text = None
|
||||
mock_part.function_call = None
|
||||
mock_part.function_response = None
|
||||
mock_part.executable_code = None
|
||||
mock_part.code_execution_result = mock_result
|
||||
mock_part.inline_data = None
|
||||
mock_part.file_data = None
|
||||
|
||||
mock_event.content.parts = [mock_part]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug(
|
||||
"Show code output",
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[Code output: Hello World\n42]" in captured.out
|
||||
assert len(events) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_inline_data(self, capsys):
|
||||
"""Test that run_debug properly handles inline data parts."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with inline data.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Event with inline data (e.g., image)
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
|
||||
mock_inline = mock.Mock()
|
||||
mock_inline.mime_type = "image/png"
|
||||
mock_inline.data = b"fake_image_data"
|
||||
|
||||
mock_part = mock.Mock()
|
||||
mock_part.text = None
|
||||
mock_part.function_call = None
|
||||
mock_part.function_response = None
|
||||
mock_part.executable_code = None
|
||||
mock_part.code_execution_result = None
|
||||
mock_part.inline_data = mock_inline
|
||||
mock_part.file_data = None
|
||||
|
||||
mock_event.content.parts = [mock_part]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug("Show image", verbose=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[Inline data: image/png]" in captured.out
|
||||
assert len(events) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_file_data(self, capsys):
|
||||
"""Test that run_debug properly handles file data parts."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with file data.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Event with file data
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
|
||||
mock_file = mock.Mock()
|
||||
mock_file.file_uri = "gs://bucket/path/to/file.pdf"
|
||||
|
||||
mock_part = mock.Mock()
|
||||
mock_part.text = None
|
||||
mock_part.function_call = None
|
||||
mock_part.function_response = None
|
||||
mock_part.executable_code = None
|
||||
mock_part.code_execution_result = None
|
||||
mock_part.inline_data = None
|
||||
mock_part.file_data = mock_file
|
||||
|
||||
mock_event.content.parts = [mock_part]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug("Reference file", verbose=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[File: gs://bucket/path/to/file.pdf]" in captured.out
|
||||
assert len(events) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_mixed_parts(self, capsys):
|
||||
"""Test that run_debug handles events with multiple part types."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with mixed parts.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Event with multiple part types
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
|
||||
# Text part
|
||||
mock_text_part = mock.Mock()
|
||||
mock_text_part.text = "Here's your result:"
|
||||
mock_text_part.function_call = None
|
||||
mock_text_part.function_response = None
|
||||
mock_text_part.executable_code = None
|
||||
mock_text_part.code_execution_result = None
|
||||
mock_text_part.inline_data = None
|
||||
mock_text_part.file_data = None
|
||||
|
||||
# Code execution part
|
||||
mock_code_part = mock.Mock()
|
||||
mock_code_part.text = None
|
||||
mock_code_part.function_call = None
|
||||
mock_code_part.function_response = None
|
||||
mock_exec_code = mock.Mock()
|
||||
mock_exec_code.language = "python"
|
||||
mock_code_part.executable_code = mock_exec_code
|
||||
mock_code_part.code_execution_result = None
|
||||
mock_code_part.inline_data = None
|
||||
mock_code_part.file_data = None
|
||||
|
||||
# Result part
|
||||
mock_result_part = mock.Mock()
|
||||
mock_result_part.text = None
|
||||
mock_result_part.function_call = None
|
||||
mock_result_part.function_response = None
|
||||
mock_result_part.executable_code = None
|
||||
mock_result = mock.Mock()
|
||||
mock_result.output = "42"
|
||||
mock_result_part.code_execution_result = mock_result
|
||||
mock_result_part.inline_data = None
|
||||
mock_result_part.file_data = None
|
||||
|
||||
mock_event.content.parts = [
|
||||
mock_text_part,
|
||||
mock_code_part,
|
||||
mock_result_part,
|
||||
]
|
||||
yield mock_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug("Mixed response", verbose=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Here's your result:" in captured.out
|
||||
assert "[Executing python code...]" in captured.out
|
||||
assert "[Code output: 42]" in captured.out
|
||||
assert len(events) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_long_output_truncation(self, capsys):
|
||||
"""Test that run_debug properly truncates long outputs."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with long outputs.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Tool call with long args
|
||||
mock_call_event = mock.Mock()
|
||||
mock_call_event.author = "test_agent"
|
||||
mock_call_event.content = mock.Mock()
|
||||
|
||||
mock_function_call = mock.Mock()
|
||||
mock_function_call.name = "process"
|
||||
# Create a long argument string
|
||||
mock_function_call.args = {"data": "x" * 100}
|
||||
|
||||
mock_part_call = mock.Mock()
|
||||
mock_part_call.text = None
|
||||
mock_part_call.function_call = mock_function_call
|
||||
mock_part_call.function_response = None
|
||||
mock_part_call.executable_code = None
|
||||
mock_part_call.code_execution_result = None
|
||||
mock_part_call.inline_data = None
|
||||
mock_part_call.file_data = None
|
||||
|
||||
mock_call_event.content.parts = [mock_part_call]
|
||||
yield mock_call_event
|
||||
|
||||
# Tool response with long result
|
||||
mock_resp_event = mock.Mock()
|
||||
mock_resp_event.author = "test_agent"
|
||||
mock_resp_event.content = mock.Mock()
|
||||
|
||||
mock_function_response = mock.Mock()
|
||||
# Create a long response string
|
||||
mock_function_response.response = {"result": "y" * 200}
|
||||
|
||||
mock_part_resp = mock.Mock()
|
||||
mock_part_resp.text = None
|
||||
mock_part_resp.function_call = None
|
||||
mock_part_resp.function_response = mock_function_response
|
||||
mock_part_resp.executable_code = None
|
||||
mock_part_resp.code_execution_result = None
|
||||
mock_part_resp.inline_data = None
|
||||
mock_part_resp.file_data = None
|
||||
|
||||
mock_resp_event.content.parts = [mock_part_resp]
|
||||
yield mock_resp_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug("Process data", verbose=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Check that args are truncated at 50 chars
|
||||
assert "..." in captured.out
|
||||
assert "[Calling tool: process(" in captured.out
|
||||
# Check that response is truncated at 100 chars
|
||||
assert "[Tool result:" in captured.out
|
||||
assert len(events) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_verbose_flag_false(self, capsys):
|
||||
"""Test that run_debug hides tool calls when verbose=False (default)."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with tools.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Tool call event
|
||||
mock_call_event = mock.Mock()
|
||||
mock_call_event.author = "test_agent"
|
||||
mock_call_event.content = mock.Mock()
|
||||
|
||||
mock_function_call = mock.Mock()
|
||||
mock_function_call.name = "get_weather"
|
||||
mock_function_call.args = {"city": "Tokyo"}
|
||||
|
||||
mock_part_call = mock.Mock()
|
||||
mock_part_call.text = None
|
||||
mock_part_call.function_call = mock_function_call
|
||||
mock_part_call.function_response = None
|
||||
mock_part_call.executable_code = None
|
||||
mock_part_call.code_execution_result = None
|
||||
mock_part_call.inline_data = None
|
||||
mock_part_call.file_data = None
|
||||
|
||||
mock_call_event.content.parts = [mock_part_call]
|
||||
yield mock_call_event
|
||||
|
||||
# Tool response event
|
||||
mock_resp_event = mock.Mock()
|
||||
mock_resp_event.author = "test_agent"
|
||||
mock_resp_event.content = mock.Mock()
|
||||
|
||||
mock_function_response = mock.Mock()
|
||||
mock_function_response.response = {"weather": "Clear, 25°C"}
|
||||
|
||||
mock_part_resp = mock.Mock()
|
||||
mock_part_resp.text = None
|
||||
mock_part_resp.function_call = None
|
||||
mock_part_resp.function_response = mock_function_response
|
||||
mock_part_resp.executable_code = None
|
||||
mock_part_resp.code_execution_result = None
|
||||
mock_part_resp.inline_data = None
|
||||
mock_part_resp.file_data = None
|
||||
|
||||
mock_resp_event.content.parts = [mock_part_resp]
|
||||
yield mock_resp_event
|
||||
|
||||
# Final text response
|
||||
mock_text_event = mock.Mock()
|
||||
mock_text_event.author = "test_agent"
|
||||
mock_text_event.content = mock.Mock()
|
||||
mock_text_event.content.parts = [
|
||||
mock.Mock(text="The weather in Tokyo is clear and 25°C.")
|
||||
]
|
||||
yield mock_text_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug(
|
||||
"What's the weather?",
|
||||
verbose=False, # Default - should NOT show tool calls
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should NOT show tool call details
|
||||
assert "[Calling tool:" not in captured.out
|
||||
assert "[Tool result:" not in captured.out
|
||||
# Should show final text response
|
||||
assert "The weather in Tokyo is clear and 25°C." in captured.out
|
||||
assert len(events) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_verbose_flag_true(self, capsys):
|
||||
"""Test that run_debug shows tool calls when verbose=True."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent with tools.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
# Tool call event
|
||||
mock_call_event = mock.Mock()
|
||||
mock_call_event.author = "test_agent"
|
||||
mock_call_event.content = mock.Mock()
|
||||
|
||||
mock_function_call = mock.Mock()
|
||||
mock_function_call.name = "calculate"
|
||||
mock_function_call.args = {"expression": "42 * 3.14"}
|
||||
|
||||
mock_part_call = mock.Mock()
|
||||
mock_part_call.text = None
|
||||
mock_part_call.function_call = mock_function_call
|
||||
mock_part_call.function_response = None
|
||||
mock_part_call.executable_code = None
|
||||
mock_part_call.code_execution_result = None
|
||||
mock_part_call.inline_data = None
|
||||
mock_part_call.file_data = None
|
||||
|
||||
mock_call_event.content.parts = [mock_part_call]
|
||||
yield mock_call_event
|
||||
|
||||
# Tool response event
|
||||
mock_resp_event = mock.Mock()
|
||||
mock_resp_event.author = "test_agent"
|
||||
mock_resp_event.content = mock.Mock()
|
||||
|
||||
mock_function_response = mock.Mock()
|
||||
mock_function_response.response = {"result": 131.88}
|
||||
|
||||
mock_part_resp = mock.Mock()
|
||||
mock_part_resp.text = None
|
||||
mock_part_resp.function_call = None
|
||||
mock_part_resp.function_response = mock_function_response
|
||||
mock_part_resp.executable_code = None
|
||||
mock_part_resp.code_execution_result = None
|
||||
mock_part_resp.inline_data = None
|
||||
mock_part_resp.file_data = None
|
||||
|
||||
mock_resp_event.content.parts = [mock_part_resp]
|
||||
yield mock_resp_event
|
||||
|
||||
# Final text response
|
||||
mock_text_event = mock.Mock()
|
||||
mock_text_event.author = "test_agent"
|
||||
mock_text_event.content = mock.Mock()
|
||||
mock_text_event.content.parts = [mock.Mock(text="The result is 131.88")]
|
||||
yield mock_text_event
|
||||
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug(
|
||||
"Calculate 42 * 3.14",
|
||||
verbose=True, # Should show tool calls
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should show tool call details
|
||||
assert (
|
||||
"[Calling tool: calculate({'expression': '42 * 3.14'})]"
|
||||
in captured.out
|
||||
)
|
||||
assert "[Tool result: {'result': 131.88}]" in captured.out
|
||||
# Should also show final text response
|
||||
assert "The result is 131.88" in captured.out
|
||||
assert len(events) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_empty_parts_list(self, capsys, caplog):
|
||||
"""Test that run_debug handles events with empty parts list gracefully."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*_args, **_kwargs):
|
||||
# Event with empty parts list
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = mock.Mock()
|
||||
mock_event.content.parts = [] # Empty parts list
|
||||
yield mock_event
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug("Test query")
|
||||
|
||||
# Should handle gracefully without crashing
|
||||
assert "User > Test query" in caplog.text
|
||||
assert len(events) == 1
|
||||
# Should not print any agent response since parts is empty
|
||||
captured = capsys.readouterr()
|
||||
assert "test_agent >" not in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_with_none_event_content(self, capsys, caplog):
|
||||
"""Test that run_debug handles events with None content gracefully."""
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
model="gemini-2.5-flash-lite",
|
||||
instruction="Test agent.",
|
||||
)
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
async def mock_run_async(*_args, **_kwargs):
|
||||
# Event with None content
|
||||
mock_event = mock.Mock()
|
||||
mock_event.author = "test_agent"
|
||||
mock_event.content = None # None content
|
||||
yield mock_event
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
with mock.patch.object(runner, "run_async", side_effect=mock_run_async):
|
||||
events = await runner.run_debug("Test query")
|
||||
|
||||
# Should handle gracefully without crashing
|
||||
assert "User > Test query" in caplog.text
|
||||
assert len(events) == 1
|
||||
# Should not print any agent response since content is None
|
||||
captured = capsys.readouterr()
|
||||
assert "test_agent >" not in captured.out
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,363 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for runner.rewind_async."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.artifacts.base_artifact_service import ensure_part
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.events.event import EventActions
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
class _NoFileDataArtifactService(InMemoryArtifactService):
|
||||
"""Artifact service that rejects file_data parts, like GCS/File services."""
|
||||
|
||||
async def save_artifact(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
filename: str,
|
||||
artifact: Union[types.Part, dict[str, Any]],
|
||||
session_id: Optional[str] = None,
|
||||
custom_metadata: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
artifact = ensure_part(artifact)
|
||||
if artifact.file_data:
|
||||
raise NotImplementedError(
|
||||
"Saving artifact with file_data is not supported."
|
||||
)
|
||||
return await super().save_artifact(
|
||||
app_name=app_name,
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
artifact=artifact,
|
||||
session_id=session_id,
|
||||
custom_metadata=custom_metadata,
|
||||
)
|
||||
|
||||
|
||||
class TestRunnerRewind:
|
||||
"""Tests for runner.rewind_async."""
|
||||
|
||||
runner: Runner
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
root_agent = BaseAgent(name="test_agent")
|
||||
session_service = InMemorySessionService()
|
||||
artifact_service = InMemoryArtifactService()
|
||||
self.runner = Runner(
|
||||
app_name="test_app",
|
||||
agent=root_agent,
|
||||
session_service=session_service,
|
||||
artifact_service=artifact_service,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewind_async_with_state_and_artifacts(self):
|
||||
"""Tests rewind_async rewinds state and artifacts."""
|
||||
runner = self.runner
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
# 1. Setup session and initial artifacts
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=runner.app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
# invocation1
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
artifact=types.Part.from_text(text="f1v0"),
|
||||
)
|
||||
event1 = Event(
|
||||
invocation_id="invocation1",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="event1")]),
|
||||
actions=EventActions(
|
||||
state_delta={"k1": "v1"}, artifact_delta={"f1": 0}
|
||||
),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event1)
|
||||
|
||||
# invocation2
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
artifact=types.Part.from_text(text="f1v1"),
|
||||
)
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f2",
|
||||
artifact=types.Part.from_text(text="f2v0"),
|
||||
)
|
||||
event2 = Event(
|
||||
invocation_id="invocation2",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="event2")]),
|
||||
actions=EventActions(
|
||||
state_delta={"k1": "v2", "k2": "v2"},
|
||||
artifact_delta={"f1": 1, "f2": 0},
|
||||
),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event2)
|
||||
|
||||
# invocation3
|
||||
event3 = Event(
|
||||
invocation_id="invocation3",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="event3")]),
|
||||
actions=EventActions(state_delta={"k2": "v3"}),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event3)
|
||||
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
assert session.state == {"k1": "v2", "k2": "v3"}
|
||||
assert await runner.artifact_service.load_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
) == types.Part.from_text(text="f1v1")
|
||||
assert await runner.artifact_service.load_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f2",
|
||||
) == types.Part.from_text(text="f2v0")
|
||||
|
||||
# 2. Rewind before invocation2
|
||||
await runner.rewind_async(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
rewind_before_invocation_id="invocation2",
|
||||
)
|
||||
|
||||
# 3. Verify state and artifacts are rewound
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
# After rewind before invocation2, only event1 state delta should apply.
|
||||
assert session.state["k1"] == "v1"
|
||||
assert not session.state["k2"]
|
||||
# f1 should be rewound to v0
|
||||
assert await runner.artifact_service.load_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
) == types.Part.from_text(text="f1v0")
|
||||
# f2 should not exist
|
||||
assert (
|
||||
await runner.artifact_service.load_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f2",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewind_async_not_first_invocation(self):
|
||||
"""Tests rewind_async rewinds state and artifacts to invocation2."""
|
||||
runner = self.runner
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
# 1. Setup session and initial artifacts
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=runner.app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
# invocation1
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
artifact=types.Part.from_text(text="f1v0"),
|
||||
)
|
||||
event1 = Event(
|
||||
invocation_id="invocation1",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="event1")]),
|
||||
actions=EventActions(
|
||||
state_delta={"k1": "v1"}, artifact_delta={"f1": 0}
|
||||
),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event1)
|
||||
|
||||
# invocation2
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
artifact=types.Part.from_text(text="f1v1"),
|
||||
)
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f2",
|
||||
artifact=types.Part.from_text(text="f2v0"),
|
||||
)
|
||||
event2 = Event(
|
||||
invocation_id="invocation2",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="event2")]),
|
||||
actions=EventActions(
|
||||
state_delta={"k1": "v2", "k2": "v2"},
|
||||
artifact_delta={"f1": 1, "f2": 0},
|
||||
),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event2)
|
||||
|
||||
# invocation3
|
||||
event3 = Event(
|
||||
invocation_id="invocation3",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="event3")]),
|
||||
actions=EventActions(state_delta={"k2": "v3"}),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event3)
|
||||
|
||||
# 2. Rewind before invocation3
|
||||
await runner.rewind_async(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
rewind_before_invocation_id="invocation3",
|
||||
)
|
||||
|
||||
# 3. Verify state and artifacts are rewound
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
# After rewind before invocation3, event1 and event2 state deltas should apply.
|
||||
assert session.state == {"k1": "v2", "k2": "v2"}
|
||||
# f1 should be v1
|
||||
assert await runner.artifact_service.load_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
) == types.Part.from_text(text="f1v1")
|
||||
# f2 should be v0
|
||||
assert await runner.artifact_service.load_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f2",
|
||||
) == types.Part.from_text(text="f2v0")
|
||||
|
||||
|
||||
class TestRunnerRewindNoFileData:
|
||||
"""Tests that rewind works with artifact services that reject file_data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewind_uses_load_artifact_not_file_data(self):
|
||||
"""Rewind must not construct file_data parts for artifact restoration.
|
||||
|
||||
GCS and File artifact services reject file_data parts. The runner
|
||||
should use load_artifact to get inline_data instead.
|
||||
"""
|
||||
root_agent = BaseAgent(name="test_agent")
|
||||
session_service = InMemorySessionService()
|
||||
artifact_service = _NoFileDataArtifactService()
|
||||
runner = Runner(
|
||||
app_name="test_app",
|
||||
agent=root_agent,
|
||||
session_service=session_service,
|
||||
artifact_service=artifact_service,
|
||||
)
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=runner.app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
# invocation1: create artifact f1 v0
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
artifact=types.Part.from_text(text="f1v0"),
|
||||
)
|
||||
event1 = Event(
|
||||
invocation_id="invocation1",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="e1")]),
|
||||
actions=EventActions(
|
||||
state_delta={"k1": "v1"}, artifact_delta={"f1": 0}
|
||||
),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event1)
|
||||
|
||||
# invocation2: update artifact f1 to v1
|
||||
await runner.artifact_service.save_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
artifact=types.Part.from_text(text="f1v1"),
|
||||
)
|
||||
event2 = Event(
|
||||
invocation_id="invocation2",
|
||||
author="agent",
|
||||
content=types.Content(parts=[types.Part.from_text(text="e2")]),
|
||||
actions=EventActions(artifact_delta={"f1": 1}),
|
||||
)
|
||||
await runner.session_service.append_event(session=session, event=event2)
|
||||
|
||||
session = await runner.session_service.get_session(
|
||||
app_name=runner.app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
# Rewind before invocation2 — this would raise NotImplementedError
|
||||
# with the old code that constructed file_data parts.
|
||||
await runner.rewind_async(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
rewind_before_invocation_id="invocation2",
|
||||
)
|
||||
|
||||
# f1 should be restored to v0 content
|
||||
restored = await runner.artifact_service.load_artifact(
|
||||
app_name=runner.app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
filename="f1",
|
||||
)
|
||||
assert restored == types.Part.from_text(text="f1v0")
|
||||
Reference in New Issue
Block a user