# 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.

"""End-to-end tests for the Task Delegation API matrix.

Covers the complete cross-product of dispatch-shape × hierarchy-depth so
the chat-coordinator wrapper, the workflow-node task path, and the
nested-delegation path are all exercised:

* LlmAgent root → single task sub-agent (basic FC delegation).
* LlmAgent root → multiple task sub-agents (sequential delegation).
* LlmAgent root → task sub-agent → nested task sub-agent (chained).
* Workflow with a task-mode node (no FC delegation).
* Workflow with a task-mode node that itself has a task sub-agent.
* Dynamic node case (task agent dispatched via ``ctx.run_node``).
"""

from __future__ import annotations

from typing import Any
from typing import AsyncGenerator

from google.adk.agents.context import Context
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.workflow import node
from google.adk.workflow import START
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import BaseModel
import pytest

from tests.unittests import testing_utils

# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------


def _delegate_part(target_name: str, request_text: str) -> types.Part:
  """LLM response calling a task sub-agent (the _TaskAgentTool FC)."""
  return types.Part.from_function_call(
      name=target_name, args={'request': request_text}
  )


def _finish_part(args: dict[str, Any]) -> types.Part:
  """LLM response calling finish_task with the given args."""
  return types.Part.from_function_call(name='finish_task', args=args)


def _text_part(text: str) -> types.Part:
  return types.Part.from_text(text=text)


def _make_task_agent(
    name: str,
    responses: list,
    *,
    sub_agents: list[LlmAgent] | None = None,
) -> LlmAgent:
  return LlmAgent(
      name=name,
      model=testing_utils.MockModel.create(responses=responses),
      mode='task',
      sub_agents=sub_agents or [],
  )


def _collect_finish_outputs(events: list[Event]) -> list[Any]:
  """Pull out finish_task FC arg dicts in chronological order."""
  out = []
  for e in events:
    for fc in e.get_function_calls():
      if fc.name == 'finish_task':
        out.append(dict(fc.args or {}))
  return out


def _get_text_responses(events: list[Event]) -> list[str]:
  """Concatenate text responses from all model events."""
  texts = []
  for e in events:
    if not e.content or not e.content.parts:
      continue
    for p in e.content.parts:
      if p.text and not p.thought:
        texts.append(p.text)
  return texts


# ---------------------------------------------------------------------------
# 1. LlmAgent root → single task sub-agent
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_chat_root_with_single_task_sub_agent(
    request: pytest.FixtureRequest,
):
  """Chat coordinator delegates to one task sub-agent and reports its output."""
  child = _make_task_agent(
      name='child',
      responses=[_finish_part({'result': 'child output'})],
  )

  root = LlmAgent(
      name='root',
      model=testing_utils.MockModel.create(
          responses=[
              _delegate_part('child', 'do the thing'),
              'All done: child output.',
          ]
      ),
      sub_agents=[child],
  )

  app = App(name=request.function.__name__, root_agent=root)
  runner = testing_utils.InMemoryRunner(app=app)

  events = await runner.run_async(testing_utils.get_user_content('hi'))

  finish_args = _collect_finish_outputs(events)
  assert finish_args == [{'result': 'child output'}]
  assert any(
      'All done: child output.' in t for t in _get_text_responses(events)
  )


# ---------------------------------------------------------------------------
# 2. LlmAgent root → multiple task sub-agents (sequential)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_chat_root_with_two_task_sub_agents_sequential(
    request: pytest.FixtureRequest,
):
  """Chat coordinator delegates to two task sub-agents in one turn."""
  collector = _make_task_agent(
      name='collector',
      responses=[_finish_part({'result': 'collected'})],
  )
  payer = _make_task_agent(
      name='payer',
      responses=[_finish_part({'result': 'paid'})],
  )

  root = LlmAgent(
      name='root',
      model=testing_utils.MockModel.create(
          responses=[
              _delegate_part('collector', 'collect'),
              _delegate_part('payer', 'pay'),
              'Order placed.',
          ]
      ),
      sub_agents=[collector, payer],
  )

  app = App(name=request.function.__name__, root_agent=root)
  runner = testing_utils.InMemoryRunner(app=app)

  events = await runner.run_async(testing_utils.get_user_content('place order'))

  finish_args = _collect_finish_outputs(events)
  assert finish_args == [{'result': 'collected'}, {'result': 'paid'}]
  assert any('Order placed.' in t for t in _get_text_responses(events))


# ---------------------------------------------------------------------------
# 3. LlmAgent root → task sub-agent → nested task sub-agent
# ---------------------------------------------------------------------------


@pytest.mark.xfail(
    reason=(
        'Task-mode wrapper does not dispatch task-delegation FCs (only the '
        'chat-mode wrapper does), so a task-mode middle agent cannot delegate '
        'to its task sub-agent.  Documented limitation.'
    ),
    strict=True,
)
@pytest.mark.asyncio
async def test_chat_root_with_nested_task_delegation(
    request: pytest.FixtureRequest,
):
  """Task agent itself has a task sub-agent and delegates further."""
  grandchild = _make_task_agent(
      name='grandchild',
      responses=[_finish_part({'result': 'leaf'})],
  )

  child = LlmAgent(
      name='child',
      model=testing_utils.MockModel.create(
          responses=[
              _delegate_part('grandchild', 'leaf work'),
              _finish_part({'result': 'middle wraps leaf'}),
          ]
      ),
      mode='task',
      sub_agents=[grandchild],
  )

  root = LlmAgent(
      name='root',
      model=testing_utils.MockModel.create(
          responses=[
              _delegate_part('child', 'do the thing'),
              'Top-level done.',
          ]
      ),
      sub_agents=[child],
  )

  app = App(name=request.function.__name__, root_agent=root)
  runner = testing_utils.InMemoryRunner(app=app)

  events = await runner.run_async(testing_utils.get_user_content('hi'))

  finish_args = _collect_finish_outputs(events)
  # grandchild fires first (deepest), then child.
  assert finish_args == [
      {'result': 'leaf'},
      {'result': 'middle wraps leaf'},
  ]
  assert any('Top-level done.' in t for t in _get_text_responses(events))


# ---------------------------------------------------------------------------
# 4. Workflow with a single task-mode node (no FC delegation)
# ---------------------------------------------------------------------------


class _CaptureNode(BaseNode):
  """Records its node_input for assertion."""

  received: list[Any] = []

  async def _run_impl(self, *, ctx, node_input):
    type(self).received.append(node_input)
    yield Event(output=node_input)


@pytest.mark.asyncio
async def test_workflow_accepts_task_mode_graph_node():
  """A mode='task' LlmAgent can be used as a static workflow graph node."""
  intake = _make_task_agent(name='intake', responses=[])
  capture = _CaptureNode(name='capture')

  wf = Workflow(name='wf', edges=[(START, intake), (intake, capture)])
  assert wf is not None


# ---------------------------------------------------------------------------
# 6. Dynamic node: function node that dispatches a task agent via ctx.run_node
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_dynamic_dispatch_of_task_agent(
    request: pytest.FixtureRequest,
):
  """A custom function node can dispatch a task agent and consume its output."""
  task_agent = _make_task_agent(
      name='task_agent',
      responses=[_finish_part({'result': 'dynamic output'})],
  )

  @node(rerun_on_resume=True)
  async def driver(*, ctx: Context, node_input: Any):
    output = await ctx.run_node(task_agent, node_input='go')
    yield Event(output=f'wrapped: {output}')

  wf = Workflow(name='wf', edges=[(START, driver)])

  app = App(name=request.function.__name__, root_agent=wf)
  runner = testing_utils.InMemoryRunner(app=app)

  events = await runner.run_async(testing_utils.get_user_content('start'))

  outputs = [e.output for e in events if e.output]
  assert any(
      isinstance(o, str) and 'dynamic output' in o for o in outputs
  ), f'expected wrapped dynamic output, got: {outputs}'


# ---------------------------------------------------------------------------
# 7. Validation error -> retry: wrapper yields the error FR and lets the LLM
#    emit a corrected finish_task on the next round.
# ---------------------------------------------------------------------------


class _StrictOutput(BaseModel):
  name: str
  age: int


@pytest.mark.asyncio
async def test_task_validation_error_drives_retry(
    request: pytest.FixtureRequest,
):
  """Bad finish_task args produce an error FR; the LLM gets a retry."""
  # First finish_task call has wrong types (age as string), second is correct.
  child_model = testing_utils.MockModel.create(
      responses=[
          _finish_part({'name': 'Jane', 'age': 'thirty'}),
          _finish_part({'name': 'Jane', 'age': 30}),
      ]
  )
  child = LlmAgent(
      name='child',
      model=child_model,
      mode='task',
      output_schema=_StrictOutput,
  )

  root = LlmAgent(
      name='root',
      model=testing_utils.MockModel.create(
          responses=[
              _delegate_part('child', 'gather identity'),
              'All set.',
          ]
      ),
      sub_agents=[child],
  )

  app = App(name=request.function.__name__, root_agent=root)
  runner = testing_utils.InMemoryRunner(app=app)

  events = await runner.run_async(testing_utils.get_user_content('hi'))

  # The mock LLM was called twice for the child (the bad attempt + the
  # corrected one), proving the wrapper looped instead of terminating
  # on the first finish_task.
  assert child_model.response_index == 1
  finish_args = _collect_finish_outputs(events)
  assert finish_args == [
      {'name': 'Jane', 'age': 'thirty'},
      {'name': 'Jane', 'age': 30},
  ]
  # The validation-error FR should be present in session for the LLM
  # to see on its retry round.
  error_frs = [
      fr.response
      for e in events
      for fr in e.get_function_responses()
      if fr.name == 'finish_task'
      and isinstance(fr.response, dict)
      and 'error' in fr.response
  ]
  assert len(error_frs) == 1, f'expected one error FR, got {error_frs}'


# ---------------------------------------------------------------------------
# 8. Cross-turn resumption: an unresolved task FC from a prior turn is
#    re-dispatched by the chat coordinator on the next user turn, before
#    the LLM is called.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_chat_coordinator_resumes_unresolved_task_fc(
    request: pytest.FixtureRequest,
):
  """Pending task FC from a prior turn is dispatched before the new LLM call."""
  child_model = testing_utils.MockModel.create(
      responses=[_finish_part({'result': 'finished after resume'})]
  )
  child = LlmAgent(name='child', model=child_model, mode='task')

  root_model = testing_utils.MockModel.create(
      responses=[
          # Only response needed: post-resume continuation after the
          # pre-LLM scan dispatches the pending task and synthesizes its FR.
          'Resumed and done.',
      ]
  )
  root = LlmAgent(
      name='root',
      model=root_model,
      sub_agents=[child],
  )

  # Seed the session with an unresolved task delegation FC authored by
  # root from a "prior turn".  No matching FR exists.
  from google.adk.sessions.in_memory_session_service import InMemorySessionService

  session_service = InMemorySessionService()
  session = await session_service.create_session(
      app_name=request.function.__name__,
      user_id='u',
  )
  await session_service.append_event(
      session=session,
      event=Event(
          invocation_id='prior-inv',
          author='root',
          content=types.Content(
              role='model',
              parts=[
                  types.Part(
                      function_call=types.FunctionCall(
                          id='fc-pending',
                          name='child',
                          args={'request': 'leftover work'},
                      )
                  )
              ],
          ),
      ),
  )

  from google.adk.runners import Runner

  app = App(name=request.function.__name__, root_agent=root)
  runner = Runner(app=app, session_service=session_service)

  events = []
  async for ev in runner.run_async(
      user_id='u',
      session_id=session.id,
      new_message=testing_utils.get_user_content('continue'),
  ):
    events.append(ev)

  # The child must have been dispatched once (resuming the pending FC).
  assert (
      child_model.response_index == 0
  ), 'child LLM should have been called exactly once for the resumed task'
  finish_args = _collect_finish_outputs(events)
  assert {
      'result': 'finished after resume'
  } in finish_args, f'expected resumed task to finish; got {finish_args}'


# ---------------------------------------------------------------------------
# 9. Strict isolation filtering: a stranger event with a foreign
#    isolation_scope must NOT appear in the task agent's LLM context.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_strict_isolation_filter_excludes_foreign_scope(
    request: pytest.FixtureRequest,
):
  """Garbage-scoped events are excluded from the task agent's view."""
  child_model = testing_utils.MockModel.create(
      responses=[_finish_part({'result': 'ok'})]
  )
  child = LlmAgent(name='child', model=child_model, mode='task')

  root = LlmAgent(
      name='root',
      model=testing_utils.MockModel.create(
          responses=[
              _delegate_part('child', 'do the thing'),
              'Done.',
          ]
      ),
      sub_agents=[child],
  )

  from google.adk.sessions.in_memory_session_service import InMemorySessionService

  session_service = InMemorySessionService()
  session = await session_service.create_session(
      app_name=request.function.__name__,
      user_id='u',
  )
  # Seed a stranger event with a different scope.
  stranger = Event(
      invocation_id='stranger-inv',
      author='someone_else',
      content=types.Content(
          role='user',
          parts=[types.Part(text='SECRET-SHOULD-NOT-LEAK')],
      ),
  )
  stranger.isolation_scope = 'garbage-scope'
  session.events.append(stranger)

  from google.adk.runners import Runner

  app = App(name=request.function.__name__, root_agent=root)
  runner = Runner(app=app, session_service=session_service)

  async for _ in runner.run_async(
      user_id='u',
      session_id=session.id,
      new_message=testing_utils.get_user_content('go'),
  ):
    pass

  # Inspect the child's LLM request: SECRET text must not appear.
  child_request = child_model.requests[0]
  rendered = '\n'.join(
      p.text or '' for c in child_request.contents or [] for p in c.parts or []
  )
  assert (
      'SECRET-SHOULD-NOT-LEAK' not in rendered
  ), 'stranger event leaked across isolation_scope filter'
