# 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.
from __future__ import annotations

import asyncio
import collections
import logging
import sys
from unittest import mock

from google.adk.agents.readonly_context import ReadonlyContext
from google.adk.code_executors.base_code_executor import BaseCodeExecutor
from google.adk.code_executors.code_execution_utils import CodeExecutionResult
from google.adk.code_executors.unsafe_local_code_executor import UnsafeLocalCodeExecutor
from google.adk.models import llm_request as llm_request_model
from google.adk.skills import models
from google.adk.tools import skill_toolset
from google.adk.tools import tool_context
from google.genai import types
import pytest


@pytest.fixture(name="mock_skill1_frontmatter")
def _mock_skill1_frontmatter():
  """Fixture for skill1 frontmatter."""
  frontmatter = mock.create_autospec(models.Frontmatter, instance=True)
  frontmatter.name = "skill1"
  frontmatter.description = "Skill 1 description"
  frontmatter.allowed_tools = ["test_tool"]
  frontmatter.metadata = {}
  frontmatter.model_dump.return_value = {
      "name": "skill1",
      "description": "Skill 1 description",
  }
  return frontmatter


@pytest.fixture(name="mock_skill1")
def _mock_skill1(mock_skill1_frontmatter):
  """Fixture for skill1."""
  skill = mock.create_autospec(models.Skill, instance=True)
  skill.name = "skill1"
  skill.description = "Skill 1 description"
  skill.instructions = "instructions for skill1"
  skill.frontmatter = mock_skill1_frontmatter
  skill.resources = mock.MagicMock(
      spec=[
          "get_reference",
          "get_asset",
          "get_script",
          "list_references",
          "list_assets",
          "list_scripts",
      ]
  )

  def get_ref(name):
    if name == "ref1.md":
      return "ref content 1"
    if name == "doc.pdf":
      return b"fake pdf content"
    return None

  def get_asset(name):
    if name == "asset1.txt":
      return "asset content 1"
    if name == "image.png":
      return b"fake image content"
    return None

  def get_script(name):
    if name == "setup.sh":
      return models.Script(src="echo setup")
    if name == "run.py":
      return models.Script(src="print('hello')")
    if name == "build.rb":
      return models.Script(src="puts 'hello'")
    return None

  skill.resources.get_reference.side_effect = get_ref
  skill.resources.get_asset.side_effect = get_asset
  skill.resources.get_script.side_effect = get_script
  skill.resources.list_references.return_value = ["ref1.md", "doc.pdf"]
  skill.resources.list_assets.return_value = ["asset1.txt", "image.png"]
  skill.resources.list_scripts.return_value = [
      "setup.sh",
      "run.py",
      "build.rb",
  ]
  return skill


@pytest.fixture(name="mock_skill2_frontmatter")
def _mock_skill2_frontmatter():
  """Fixture for skill2 frontmatter."""
  frontmatter = mock.create_autospec(models.Frontmatter, instance=True)
  frontmatter.name = "skill2"
  frontmatter.description = "Skill 2 description"
  frontmatter.allowed_tools = []
  frontmatter.metadata = {}
  frontmatter.model_dump.return_value = {
      "name": "skill2",
      "description": "Skill 2 description",
  }
  return frontmatter


@pytest.fixture(name="mock_skill2")
def _mock_skill2(mock_skill2_frontmatter):
  """Fixture for skill2."""
  skill = mock.create_autospec(models.Skill, instance=True)
  skill.name = "skill2"
  skill.description = "Skill 2 description"
  skill.instructions = "instructions for skill2"
  skill.frontmatter = mock_skill2_frontmatter
  skill.resources = mock.MagicMock(
      spec=[
          "get_reference",
          "get_asset",
          "get_script",
          "list_references",
          "list_assets",
          "list_scripts",
      ]
  )

  def get_ref(name):
    if name == "ref2.md":
      return "ref content 2"
    return None

  def get_asset(name):
    if name == "asset2.txt":
      return "asset content 2"
    return None

  skill.resources.get_reference.side_effect = get_ref
  skill.resources.get_asset.side_effect = get_asset
  skill.resources.list_references.return_value = ["ref2.md"]
  skill.resources.list_assets.return_value = ["asset2.txt"]
  skill.resources.list_scripts.return_value = []
  return skill


@pytest.fixture
def tool_context_instance():
  """Fixture for tool context."""
  ctx = mock.create_autospec(tool_context.ToolContext, instance=True)
  ctx._invocation_context = mock.MagicMock()
  ctx._invocation_context.agent = mock.MagicMock()
  ctx._invocation_context.agent.name = "test_agent"
  ctx._invocation_context.agent_states = {}
  ctx.agent_name = "test_agent"
  return ctx


# SkillToolset tests
def test_get_skill(mock_skill1, mock_skill2):
  toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
  assert toolset._get_skill("skill1") == mock_skill1
  assert toolset._get_skill("nonexistent") is None


def test_list_skills(mock_skill1, mock_skill2):
  toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
  skills = toolset._list_skills()
  assert len(skills) == 2
  assert mock_skill1 in skills
  assert mock_skill2 in skills


def test_clone_with_updated_skills(mock_skill1, mock_skill2):
  """Tests that the skills are updated but other properties are retained."""
  mock_skill3 = mock.create_autospec(models.Skill, instance=True)
  mock_skill3.name = "skill3"

  mock_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
  mock_tool.name = "my_tool"

  registry = mock.create_autospec(skill_toolset.SkillRegistry, instance=True)

  executor = _make_mock_executor()

  toolset = skill_toolset.SkillToolset(
      [mock_skill1, mock_skill2],
      registry=registry,
      code_executor=executor,
      script_timeout=42,
      additional_tools=[mock_tool],
  )

  new_toolset = toolset.clone_with_updated_skills([mock_skill3])

  # Verify new skill is present and old ones are gone
  skills = new_toolset._list_skills()
  assert len(skills) == 1
  assert skills[0] == mock_skill3

  # Verify properties are retained
  assert new_toolset._registry is registry
  assert new_toolset._code_executor is executor
  assert new_toolset._script_timeout == 42
  assert "my_tool" in new_toolset._provided_tools_by_name


@pytest.mark.asyncio
async def test_get_tools(mock_skill1, mock_skill2):
  toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
  tools = await toolset.get_tools()
  assert len(tools) == 4
  assert isinstance(tools[0], skill_toolset.ListSkillsTool)
  assert isinstance(tools[1], skill_toolset.LoadSkillTool)
  assert isinstance(tools[2], skill_toolset.LoadSkillResourceTool)
  assert isinstance(tools[3], skill_toolset.RunSkillScriptTool)


@pytest.mark.asyncio
async def test_resolve_additional_tools_from_state_none(mock_skill1):
  toolset = skill_toolset.SkillToolset([mock_skill1])

  # Mock ReadonlyContext
  readonly_context = mock.create_autospec(ReadonlyContext, instance=True)
  readonly_context.agent_name = "test_agent"
  readonly_context.state.get.return_value = None

  result = await toolset._resolve_additional_tools_from_state(readonly_context)

  assert not result


@pytest.mark.asyncio
async def test_list_skills_tool(
    mock_skill1, mock_skill2, tool_context_instance
):
  toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
  tool = skill_toolset.ListSkillsTool(toolset)
  result = await tool.run_async(args={}, tool_context=tool_context_instance)
  assert "<available_skills>" in result
  assert "skill1" in result
  assert "skill2" in result


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "args, expected_result",
    [
        (
            {"skill_name": "skill1"},
            {
                "skill_name": "skill1",
                "instructions": "instructions for skill1",
                "frontmatter": {
                    "name": "skill1",
                    "description": "Skill 1 description",
                },
            },
        ),
        (
            {"skill_name": "nonexistent"},
            {
                "error": "Skill 'nonexistent' not found.",
                "error_code": "SKILL_NOT_FOUND",
            },
        ),
        (
            {},
            {
                "error": "Argument 'skill_name' is required.",
                "error_code": "INVALID_ARGUMENTS",
            },
        ),
    ],
)
async def test_load_skill_run_async(
    mock_skill1, tool_context_instance, args, expected_result
):
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillTool(toolset)
  result = await tool.run_async(args=args, tool_context=tool_context_instance)
  assert result == expected_result


@pytest.mark.asyncio
async def test_load_skill_run_async_state_none(
    mock_skill1, tool_context_instance
):
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillTool(toolset)

  # Mock state to return None for the key
  state_key = "_adk_activated_skill_test_agent"
  tool_context_instance.state.get.return_value = None

  result = await tool.run_async(
      args={"skill_name": "skill1"}, tool_context=tool_context_instance
  )

  assert result["skill_name"] == "skill1"
  tool_context_instance.state.__setitem__.assert_called_with(
      state_key, ["skill1"]
  )


@pytest.mark.asyncio
async def test_load_skill_run_async_injects_state_when_opt_in(
    mock_skill1, mock_skill1_frontmatter, tool_context_instance
):
  mock_skill1.instructions = "Hello {user_name}!"
  mock_skill1_frontmatter.metadata = {"adk_inject_state": True}
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillTool(toolset)

  with mock.patch.object(
      skill_toolset.instructions_utils,
      "inject_session_state",
      autospec=True,
  ) as mock_inject:
    mock_inject.return_value = "Hello Alice!"
    result = await tool.run_async(
        args={"skill_name": "skill1"}, tool_context=tool_context_instance
    )

  mock_inject.assert_awaited_once()
  call_args = mock_inject.await_args
  assert call_args.args[0] == "Hello {user_name}!"
  assert result["instructions"] == "Hello Alice!"


@pytest.mark.asyncio
async def test_load_skill_run_async_skips_injection_when_opt_out(
    mock_skill1, mock_skill1_frontmatter, tool_context_instance
):
  mock_skill1.instructions = "Hello {user_name}!"
  mock_skill1_frontmatter.metadata = {"adk_inject_state": False}
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillTool(toolset)

  with mock.patch.object(
      skill_toolset.instructions_utils,
      "inject_session_state",
      autospec=True,
  ) as mock_inject:
    result = await tool.run_async(
        args={"skill_name": "skill1"}, tool_context=tool_context_instance
    )

  mock_inject.assert_not_called()
  assert result["instructions"] == "Hello {user_name}!"


@pytest.mark.asyncio
async def test_load_skill_run_async_skips_injection_when_metadata_absent(
    mock_skill1, tool_context_instance
):
  mock_skill1.instructions = "Hello {user_name}!"
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillTool(toolset)

  with mock.patch.object(
      skill_toolset.instructions_utils,
      "inject_session_state",
      autospec=True,
  ) as mock_inject:
    result = await tool.run_async(
        args={"skill_name": "skill1"}, tool_context=tool_context_instance
    )

  mock_inject.assert_not_called()
  assert result["instructions"] == "Hello {user_name}!"


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "args, expected_result",
    [
        (
            {"skill_name": "skill1", "file_path": "references/ref1.md"},
            {
                "skill_name": "skill1",
                "file_path": "references/ref1.md",
                "content": "ref content 1",
            },
        ),
        (
            {"skill_name": "skill1", "file_path": "assets/asset1.txt"},
            {
                "skill_name": "skill1",
                "file_path": "assets/asset1.txt",
                "content": "asset content 1",
            },
        ),
        (
            {"skill_name": "skill1", "file_path": "references/doc.pdf"},
            {
                "skill_name": "skill1",
                "file_path": "references/doc.pdf",
                "status": (
                    "Binary file detected. The content has been injected into"
                    " the conversation history for you to analyze."
                ),
            },
        ),
        (
            {"skill_name": "skill1", "file_path": "assets/image.png"},
            {
                "skill_name": "skill1",
                "file_path": "assets/image.png",
                "status": (
                    "Binary file detected. The content has been injected into"
                    " the conversation history for you to analyze."
                ),
            },
        ),
        (
            {"skill_name": "skill1", "file_path": "scripts/setup.sh"},
            {
                "skill_name": "skill1",
                "file_path": "scripts/setup.sh",
                "content": "echo setup",
            },
        ),
        (
            {"skill_name": "nonexistent", "file_path": "references/ref1.md"},
            {
                "error": "Skill 'nonexistent' not found.",
                "error_code": "SKILL_NOT_FOUND",
            },
        ),
        # RESOURCE_NOT_FOUND is tested separately in
        # test_load_resource_first_missing_returns_soft_error because the
        # counter guard requires a real state dict (mock state.get() returns a
        # truthy MagicMock that int() coerces to 1, skipping the soft path).
        (
            {"skill_name": "skill1", "file_path": "invalid/path.txt"},
            {
                "error": (
                    "Path must start with 'references/', 'assets/',"
                    " or 'scripts/'."
                ),
                "error_code": "INVALID_RESOURCE_PATH",
            },
        ),
        (
            {"file_path": "references/ref1.md"},
            {
                "error": "Argument 'skill_name' is required.",
                "error_code": "INVALID_ARGUMENTS",
            },
        ),
        (
            {"skill_name": "skill1"},
            {
                "error": "Argument 'file_path' is required.",
                "error_code": "INVALID_ARGUMENTS",
            },
        ),
    ],
)
async def test_load_resource_run_async(
    mock_skill1, tool_context_instance, args, expected_result
):
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)
  result = await tool.run_async(args=args, tool_context=tool_context_instance)
  assert result == expected_result


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "resource_path, expected_mime, fake_content",
    [
        ("references/doc.pdf", "application/pdf", b"fake pdf content"),
        ("assets/image.png", "image/png", b"fake image content"),
    ],
)
async def test_load_resource_process_llm_request_binary(
    mock_skill1,
    tool_context_instance,
    resource_path,
    expected_mime,
    fake_content,
):
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)

  llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True)

  part = types.Part.from_function_response(
      name=tool.name,
      response={
          "skill_name": "skill1",
          "file_path": resource_path,
          "status": (
              "Binary file detected. The content has been injected into the"
              " conversation history for you to analyze."
          ),
      },
  )
  content = types.Content(role="model", parts=[part])
  llm_req.contents = [content]

  await tool.process_llm_request(
      tool_context=tool_context_instance, llm_request=llm_req
  )

  assert len(llm_req.contents) == 2
  injected_content = llm_req.contents[1]
  assert injected_content.role == "user"
  assert len(injected_content.parts) == 2
  assert (
      f"The content of binary file '{resource_path}' is:"
      in injected_content.parts[0].text
  )
  assert injected_content.parts[1].inline_data.data == fake_content
  assert injected_content.parts[1].inline_data.mime_type == expected_mime


@pytest.mark.asyncio
async def test_process_llm_request_with_list_skills_tool(
    mock_skill1, mock_skill2, tool_context_instance
):
  toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
  llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True)

  await toolset.process_llm_request(
      tool_context=tool_context_instance, llm_request=llm_req
  )

  llm_req.append_instructions.assert_called_once_with(
      [skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION]
  )


@pytest.mark.asyncio
async def test_process_llm_request_without_list_skills_tool(
    mock_skill1, mock_skill2, tool_context_instance
):
  toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
  # Manually remove ListSkillsTool from self._tools to simulate it not being available
  toolset._tools = [
      t
      for t in toolset._tools
      if not isinstance(t, skill_toolset.ListSkillsTool)
  ]

  llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True)

  await toolset.process_llm_request(
      tool_context=tool_context_instance, llm_request=llm_req
  )

  llm_req.append_instructions.assert_called_once()
  args, _ = llm_req.append_instructions.call_args
  instructions = args[0]
  assert len(instructions) == 2
  assert instructions[0] == skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION
  assert "<available_skills>" in instructions[1]
  assert "skill1" in instructions[1]
  assert "skill2" in instructions[1]


def test_duplicate_skill_name_raises(mock_skill1):
  skill_dup = mock.create_autospec(models.Skill, instance=True)
  skill_dup.name = "skill1"
  with pytest.raises(ValueError, match="Duplicate skill name"):
    skill_toolset.SkillToolset([mock_skill1, skill_dup])


@pytest.mark.asyncio
async def test_scripts_resource_not_found(mock_skill1):
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "scripts/nonexistent.sh"},
      tool_context=ctx,
  )
  assert result["error_code"] == "RESOURCE_NOT_FOUND"


@pytest.mark.asyncio
async def test_load_resource_first_missing_returns_soft_error(mock_skill1):
  """First RESOURCE_NOT_FOUND in an invocation returns the soft error code."""
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "references/other.md"},
      tool_context=ctx,
  )
  assert result["error_code"] == "RESOURCE_NOT_FOUND"
  assert result["error"] == (
      "Resource 'references/other.md' not found in skill 'skill1'."
  )


@pytest.mark.asyncio
async def test_load_resource_repeated_failure_escalates_to_fatal(mock_skill1):
  """Any second RESOURCE_NOT_FOUND within an invocation returns RESOURCE_NOT_FOUND_FATAL."""
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)
  ctx = _make_tool_context_with_agent()

  args = {"skill_name": "skill1", "file_path": "references/nonexistent.md"}

  result1 = await tool.run_async(args=args, tool_context=ctx)
  assert result1["error_code"] == "RESOURCE_NOT_FOUND"

  result2 = await tool.run_async(args=args, tool_context=ctx)
  assert result2["error_code"] == "RESOURCE_NOT_FOUND_FATAL"
  assert "Do not retry" in result2["error"]
  assert "stop" in result2["error"].lower()
  assert "failure #2" in result2["error"]


@pytest.mark.asyncio
async def test_load_resource_different_path_also_escalates_to_fatal(
    mock_skill1,
):
  """A different missing path on the second call still escalates to RESOURCE_NOT_FOUND_FATAL.

  The counter is path-agnostic: any second not-found within the same invocation
  is fatal, even when the LLM hallucinates a different path on each retry.
  """
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)
  ctx = _make_tool_context_with_agent()

  result1 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "references/missing_a.md"},
      tool_context=ctx,
  )
  assert result1["error_code"] == "RESOURCE_NOT_FOUND"

  result2 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "references/missing_b.md"},
      tool_context=ctx,
  )
  assert result2["error_code"] == "RESOURCE_NOT_FOUND_FATAL"
  assert "Do not retry" in result2["error"]


@pytest.mark.asyncio
async def test_load_resource_failures_isolated_per_invocation(mock_skill1):
  """Failure counter does not leak across invocations.

  A RESOURCE_NOT_FOUND in invocation A must not increment invocation B's
  counter; invocation B's first missing-resource call must still return the
  soft error, even when both invocations share the same session state dict.
  """
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)

  shared_state = {}
  ctx_a = _make_tool_context_with_agent(invocation_id="inv_a")
  ctx_a.state = shared_state
  ctx_b = _make_tool_context_with_agent(invocation_id="inv_b")
  ctx_b.state = shared_state

  # invocation A: one failure — counter for inv_a reaches 1 (soft).
  result_a = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "references/typo.md"},
      tool_context=ctx_a,
  )
  assert result_a["error_code"] == "RESOURCE_NOT_FOUND"

  # invocation B, first attempt (different path) — counter for inv_b = 1 (soft).
  result_b1 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "references/typo.md"},
      tool_context=ctx_b,
  )
  assert result_b1["error_code"] == "RESOURCE_NOT_FOUND"

  # invocation B, second attempt (different path) — counter for inv_b = 2 (fatal).
  result_b2 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "references/other.md"},
      tool_context=ctx_b,
  )
  assert result_b2["error_code"] == "RESOURCE_NOT_FOUND_FATAL"


@pytest.mark.asyncio
async def test_load_resource_counter_uses_temp_prefix(mock_skill1):
  """Failure-counter key uses the `temp:` prefix so it is not persisted."""
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.LoadSkillResourceTool(toolset)
  ctx = _make_tool_context_with_agent()

  await tool.run_async(
      args={"skill_name": "skill1", "file_path": "references/missing.md"},
      tool_context=ctx,
  )

  # The counter key must start with `temp:` so it is trimmed from the event
  # delta and never reaches durable storage.
  guard_keys = [k for k in ctx.state if "skill_resource_not_found_count" in k]
  assert guard_keys, "Failure counter did not write a tracking key."
  assert all(k.startswith("temp:") for k in guard_keys)


# RunSkillScriptTool tests


def _make_tool_context_with_agent(agent=None, invocation_id="test_invocation"):
  """Creates a mock ToolContext with _invocation_context.agent."""
  ctx = mock.MagicMock(spec=tool_context.ToolContext)
  ctx._invocation_context = mock.MagicMock()
  ctx._invocation_context.agent = agent or mock.MagicMock()
  ctx._invocation_context.agent.name = "test_agent"
  ctx._invocation_context.agent_states = {}
  ctx.agent_name = "test_agent"
  ctx.invocation_id = invocation_id
  ctx.state = {}
  return ctx


def _make_mock_executor(stdout="", stderr=""):
  """Creates a mock code executor that returns the given output."""
  executor = mock.create_autospec(BaseCodeExecutor, instance=True)
  executor.execute_code.return_value = CodeExecutionResult(
      stdout=stdout, stderr=stderr
  )
  return executor


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "args, expected_error_code",
    [
        (
            {"file_path": "setup.sh"},
            "INVALID_ARGUMENTS",
        ),
        (
            {"skill_name": "skill1"},
            "INVALID_ARGUMENTS",
        ),
        (
            {"skill_name": "", "file_path": "setup.sh"},
            "INVALID_ARGUMENTS",
        ),
        (
            {"skill_name": "skill1", "file_path": ""},
            "INVALID_ARGUMENTS",
        ),
    ],
)
async def test_execute_script_missing_params(
    mock_skill1, args, expected_error_code
):
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(args=args, tool_context=ctx)
  assert result["error_code"] == expected_error_code


@pytest.mark.asyncio
async def test_execute_script_skill_not_found(mock_skill1):
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "nonexistent", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["error_code"] == "SKILL_NOT_FOUND"


@pytest.mark.asyncio
async def test_execute_script_script_not_found(mock_skill1):
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "nonexistent.py"},
      tool_context=ctx,
  )
  assert result["error_code"] == "SCRIPT_NOT_FOUND"
  assert result["error"] == (
      "Script 'nonexistent.py' not found in skill 'skill1'."
  )


@pytest.mark.asyncio
async def test_execute_script_repeated_failure_escalates_to_fatal(mock_skill1):
  """Any second SCRIPT_NOT_FOUND within an invocation returns SCRIPT_NOT_FOUND_FATAL."""
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()

  args = {"skill_name": "skill1", "file_path": "scripts/nonexistent.py"}

  result1 = await tool.run_async(args=args, tool_context=ctx)
  assert result1["error_code"] == "SCRIPT_NOT_FOUND"

  result2 = await tool.run_async(args=args, tool_context=ctx)
  assert result2["error_code"] == "SCRIPT_NOT_FOUND_FATAL"
  assert "Do not retry" in result2["error"]
  assert "stop" in result2["error"].lower()
  assert "failure #2" in result2["error"]


@pytest.mark.asyncio
async def test_execute_script_different_path_also_escalates_to_fatal(
    mock_skill1,
):
  """A different missing script on the second call still escalates to SCRIPT_NOT_FOUND_FATAL.

  The counter is path-agnostic: any second not-found within the same invocation
  is fatal, even when the LLM hallucinates a different script path on each
  retry.
  """
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()

  result1 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "scripts/missing_a.py"},
      tool_context=ctx,
  )
  assert result1["error_code"] == "SCRIPT_NOT_FOUND"

  result2 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "scripts/missing_b.py"},
      tool_context=ctx,
  )
  assert result2["error_code"] == "SCRIPT_NOT_FOUND_FATAL"
  assert "Do not retry" in result2["error"]


@pytest.mark.asyncio
async def test_execute_script_failures_isolated_per_invocation(mock_skill1):
  """Failure counter does not leak across invocations.

  A SCRIPT_NOT_FOUND in invocation A must not increment invocation B's
  counter; invocation B's first missing-script call must still return the
  soft error, even when both invocations share the same session state dict.
  """
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)

  shared_state = {}
  ctx_a = _make_tool_context_with_agent(invocation_id="inv_a")
  ctx_a.state = shared_state
  ctx_b = _make_tool_context_with_agent(invocation_id="inv_b")
  ctx_b.state = shared_state

  # invocation A: one failure — counter for inv_a reaches 1 (soft).
  result_a = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "scripts/typo.py"},
      tool_context=ctx_a,
  )
  assert result_a["error_code"] == "SCRIPT_NOT_FOUND"

  # invocation B, first attempt (same path) — counter for inv_b = 1 (soft).
  result_b1 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "scripts/typo.py"},
      tool_context=ctx_b,
  )
  assert result_b1["error_code"] == "SCRIPT_NOT_FOUND"

  # invocation B, second attempt (different path) — counter for inv_b = 2 (fatal).
  result_b2 = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "scripts/other.py"},
      tool_context=ctx_b,
  )
  assert result_b2["error_code"] == "SCRIPT_NOT_FOUND_FATAL"


@pytest.mark.asyncio
async def test_execute_script_counter_uses_temp_prefix(mock_skill1):
  """Failure-counter key uses the `temp:` prefix so it is not persisted."""
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()

  await tool.run_async(
      args={"skill_name": "skill1", "file_path": "scripts/missing.py"},
      tool_context=ctx,
  )

  # The counter key must start with `temp:` so it is trimmed from the event
  # delta and never reaches durable storage.
  guard_keys = [k for k in ctx.state if "skill_script_not_found_count" in k]
  assert guard_keys, "Failure counter did not write a tracking key."
  assert all(k.startswith("temp:") for k in guard_keys)


@pytest.mark.asyncio
async def test_execute_script_no_code_executor(mock_skill1):
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  # Agent without code_executor attribute
  agent = mock.MagicMock(spec=[])
  ctx = _make_tool_context_with_agent(agent=agent)
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["error_code"] == "NO_CODE_EXECUTOR"


@pytest.mark.asyncio
async def test_execute_script_agent_code_executor_none(mock_skill1):
  """Agent has code_executor attr but it's None."""
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  agent = mock.MagicMock()
  agent.code_executor = None
  ctx = _make_tool_context_with_agent(agent=agent)
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["error_code"] == "NO_CODE_EXECUTOR"


@pytest.mark.asyncio
async def test_execute_script_unsupported_type(mock_skill1):
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "build.rb"},
      tool_context=ctx,
  )
  assert result["error_code"] == "UNSUPPORTED_SCRIPT_TYPE"


@pytest.mark.asyncio
async def test_execute_script_python_success(mock_skill1):
  executor = _make_mock_executor(stdout="hello\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["status"] == "success"
  assert result["stdout"] == "hello\n"
  assert result["stderr"] == ""
  assert result["skill_name"] == "skill1"
  assert result["file_path"] == "run.py"

  # Verify the code passed to executor runs the python scripts
  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert "_materialize_and_run()" in code_input.code
  assert "import runpy" in code_input.code
  assert "sys.argv = ['scripts/run.py']" in code_input.code
  assert (
      "sys.path.insert(0, os.path.dirname(os.path.abspath('scripts/run.py')))"
      in code_input.code
  )
  assert (
      "runpy.run_path('scripts/run.py', run_name='__main__')" in code_input.code
  )


@pytest.mark.asyncio
async def test_execute_script_shell_success(mock_skill1):
  executor = _make_mock_executor(stdout="setup\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["status"] == "success"
  assert result["stdout"] == "setup\n"

  # Verify the code wraps in subprocess.run with JSON envelope
  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert "subprocess.run" in code_input.code
  assert "bash" in code_input.code
  assert "__shell_result__" in code_input.code


@pytest.mark.asyncio
async def test_build_wrapper_code_with_unicode(mock_skill1):
  """Verify that generated code uses utf-8 encoding for materializing files."""
  # Add unicode content to mock_skill1 resources
  unicode_content = "你好"
  mock_skill1.resources.list_references.return_value = ["unicode.txt"]
  mock_skill1.resources.get_reference.side_effect = lambda name: (
      unicode_content if name == "unicode.txt" else None
  )

  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert "encoding='utf-8' if mode == 'w' else None" in code_input.code
  assert unicode_content in code_input.code


@pytest.mark.asyncio
async def test_execute_script_with_input_args_python(mock_skill1):
  executor = _make_mock_executor(stdout="done\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "run.py",
          "args": {"verbose": True, "count": "3"},
      },
      tool_context=ctx,
  )
  assert result["status"] == "success"

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert (
      "['scripts/run.py', '--verbose', 'True', '--count', '3']"
      in code_input.code
  )


@pytest.mark.asyncio
async def test_execute_script_with_input_args_shell(mock_skill1):
  executor = _make_mock_executor(stdout="done\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "setup.sh",
          "args": {"force": True},
      },
      tool_context=ctx,
  )
  assert result["status"] == "success"

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert "['bash', 'scripts/setup.sh', '--force', 'True']" in code_input.code


@pytest.mark.asyncio
async def test_execute_script_with_list_args_python(
    mock_skill1,
):
  """Verifies that python scripts can be executed with list arguments."""
  executor = _make_mock_executor(stdout="done\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "run.py",
          "args": ["--verbose", "True", "-n", "5", "input.txt"],
      },
      tool_context=ctx,
  )
  assert result["status"] == "success"

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert (
      "['scripts/run.py', '--verbose', 'True', '-n', '5', 'input.txt']"
      in code_input.code
  )


@pytest.mark.asyncio
async def test_execute_script_with_list_args_shell(
    mock_skill1,
):
  """Verifies that shell scripts can be executed with list arguments."""
  executor = _make_mock_executor(stdout="done\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "setup.sh",
          "args": ["-n", "5", "input.txt"],
      },
      tool_context=ctx,
  )
  assert result["status"] == "success"

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert (
      "['bash', 'scripts/setup.sh', '-n', '5', 'input.txt']" in code_input.code
  )


@pytest.mark.asyncio
async def test_execute_script_with_list_args_rejects_others_python(
    mock_skill1,  # pylint: disable=redefined-outer-name
):
  """Verifies that short_options and positional_args are rejected when args is a list for Python scripts."""
  executor = _make_mock_executor(stdout="done\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "run.py",
          "args": ["arg1", "arg2"],
          "short_options": {"v": True},
          "positional_args": ["pos1"],
      },
      tool_context=ctx,
  )
  assert result["error_code"] == "INVALID_ARGUMENTS"
  assert (
      "Cannot specify 'short_options' or 'positional_args'" in result["error"]
  )


@pytest.mark.asyncio
async def test_execute_script_with_list_args_rejects_others_shell(
    mock_skill1,  # pylint: disable=redefined-outer-name
):
  """Verifies that short_options and positional_args are rejected when args is a list for shell scripts."""
  executor = _make_mock_executor(stdout="done\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "setup.sh",
          "args": ["arg1", "arg2"],
          "short_options": {"v": True},
          "positional_args": ["pos1"],
      },
      tool_context=ctx,
  )
  assert result["error_code"] == "INVALID_ARGUMENTS"
  assert (
      "Cannot specify 'short_options' or 'positional_args'" in result["error"]
  )


@pytest.mark.asyncio
async def test_execute_script_scripts_prefix_stripping(mock_skill1):
  executor = _make_mock_executor(stdout="setup\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "scripts/setup.sh",
      },
      tool_context=ctx,
  )
  assert result["status"] == "success"
  assert result["file_path"] == "scripts/setup.sh"


@pytest.mark.asyncio
async def test_execute_script_toolset_executor_priority(mock_skill1):
  """Toolset-level executor takes priority over agent's."""
  toolset_executor = _make_mock_executor(stdout="from toolset\n")
  agent_executor = _make_mock_executor(stdout="from agent\n")
  toolset = skill_toolset.SkillToolset(
      [mock_skill1], code_executor=toolset_executor
  )
  tool = skill_toolset.RunSkillScriptTool(toolset)
  agent = mock.MagicMock()
  agent.code_executor = agent_executor
  ctx = _make_tool_context_with_agent(agent=agent)
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["stdout"] == "from toolset\n"
  toolset_executor.execute_code.assert_called_once()
  agent_executor.execute_code.assert_not_called()


@pytest.mark.asyncio
async def test_execute_script_agent_executor_fallback(mock_skill1):
  """Falls back to agent's code executor when toolset has none."""
  agent_executor = _make_mock_executor(stdout="from agent\n")
  toolset = skill_toolset.SkillToolset([mock_skill1])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  agent = mock.MagicMock()
  agent.code_executor = agent_executor
  ctx = _make_tool_context_with_agent(agent=agent)
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["stdout"] == "from agent\n"
  agent_executor.execute_code.assert_called_once()


@pytest.mark.asyncio
async def test_execute_script_execution_error(mock_skill1):
  executor = _make_mock_executor()
  executor.execute_code.side_effect = RuntimeError("boom")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["error_code"] == "EXECUTION_ERROR"
  assert "boom" in result["error"]
  assert result["error"].startswith("Failed to execute script 'run.py':")


@pytest.mark.asyncio
async def test_execute_script_stderr_only_sets_error_status(mock_skill1):
  """stderr with no stdout should report error status."""
  executor = _make_mock_executor(stdout="", stderr="fatal error\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["status"] == "error"
  assert result["stderr"] == "fatal error\n"


@pytest.mark.asyncio
async def test_execute_script_stderr_with_stdout_sets_warning(mock_skill1):
  """stderr alongside stdout should report warning status."""
  executor = _make_mock_executor(stdout="output\n", stderr="deprecation\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["status"] == "warning"
  assert result["stdout"] == "output\n"
  assert result["stderr"] == "deprecation\n"


@pytest.mark.asyncio
async def test_execute_script_execution_error_truncated(mock_skill1):
  """Long exception messages are truncated to avoid wasting LLM tokens."""
  executor = _make_mock_executor()
  executor.execute_code.side_effect = RuntimeError("x" * 300)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["error_code"] == "EXECUTION_ERROR"
  # 200 chars of the message + "..." suffix + the prefix
  assert result["error"].endswith("...")
  assert len(result["error"]) < 300


@pytest.mark.asyncio
async def test_execute_script_system_exit_caught(mock_skill1):
  """sys.exit() in a script should not terminate the process."""
  executor = _make_mock_executor()
  executor.execute_code.side_effect = SystemExit(1)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["error_code"] == "EXECUTION_ERROR"
  assert "exited with code 1" in result["error"]


@pytest.mark.asyncio
async def test_execute_script_system_exit_zero_is_success(mock_skill1):
  """sys.exit(0) is a normal termination and should report success."""
  executor = _make_mock_executor()
  executor.execute_code.side_effect = SystemExit(0)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()

  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["status"] == "success"


@pytest.mark.asyncio
async def test_execute_script_system_exit_none_is_success(mock_skill1):
  """sys.exit() with no arg (None) should report success."""
  executor = _make_mock_executor()
  executor.execute_code.side_effect = SystemExit(None)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )
  assert result["status"] == "success"


@pytest.mark.asyncio
async def test_execute_script_shell_includes_timeout(mock_skill1):
  """Shell wrapper includes timeout in subprocess.run."""
  executor = _make_mock_executor(stdout="ok\n")
  toolset = skill_toolset.SkillToolset(
      [mock_skill1], code_executor=executor, script_timeout=60
  )
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["status"] == "success"
  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert "timeout=60" in code_input.code


@pytest.mark.asyncio
async def test_execute_script_extensionless_unsupported(mock_skill1):
  """Files without extensions should return UNSUPPORTED_SCRIPT_TYPE."""
  # Add a script with no extension to the mock
  original_side_effect = mock_skill1.resources.get_script.side_effect

  def get_script_extended(name):
    if name == "noext":
      return models.Script(src="print('hi')")
    return original_side_effect(name)

  mock_skill1.resources.get_script.side_effect = get_script_extended

  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "noext"},
      tool_context=ctx,
  )
  assert result["error_code"] == "UNSUPPORTED_SCRIPT_TYPE"


# ── Integration tests using real UnsafeLocalCodeExecutor ──


def _make_skill_with_script(
    skill_name: str, script_name: str, script: models.Script
) -> models.Skill:
  """Creates a minimal mock Skill with a single script."""
  return _make_skill_with_scripts(skill_name, {script_name: script})


def _make_skill_with_scripts(
    skill_name: str, scripts: dict[str, models.Script]
) -> models.Skill:
  """Creates a minimal mock Skill with scripts."""
  skill = mock.create_autospec(models.Skill, instance=True)
  skill.name = skill_name
  skill.description = f"Test skill {skill_name}"
  skill.instructions = "test instructions"
  fm = mock.create_autospec(models.Frontmatter, instance=True)
  fm.name = skill_name
  fm.description = f"Test skill {skill_name}"
  skill.frontmatter = fm
  skill.resources = mock.MagicMock(
      spec=[
          "get_reference",
          "get_asset",
          "get_script",
          "list_references",
          "list_assets",
          "list_scripts",
      ]
  )

  def get_script(name):
    return scripts.get(name)

  skill.resources.get_script.side_effect = get_script
  skill.resources.get_reference.return_value = None
  skill.resources.get_asset.return_value = None
  skill.resources.list_references.return_value = []
  skill.resources.list_assets.return_value = []
  skill.resources.list_scripts.return_value = list(scripts)
  return skill


def _make_real_executor_toolset(skills, **kwargs):
  """Creates a SkillToolset with a real UnsafeLocalCodeExecutor."""

  if sys.executable is None:
    sys.executable = "/usr/bin/python3"

  executor = UnsafeLocalCodeExecutor(timeout_seconds=60)
  return skill_toolset.SkillToolset(skills, code_executor=executor, **kwargs)


@pytest.mark.asyncio
async def test_integration_python_stdout():
  """Real executor: Python script stdout is captured."""
  script = models.Script(src="print('hello world')")
  skill = _make_skill_with_script("test_skill", "hello.py", script)
  toolset = _make_real_executor_toolset([skill])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "test_skill",
          "file_path": "hello.py",
      },
      tool_context=ctx,
  )
  assert "status" in result, f"Result missing status: {result}"
  assert result["status"] == "success"
  assert result["stdout"] == "hello world\n"
  assert result["stderr"] == ""


@pytest.mark.asyncio
async def test_integration_python_unicode_materialization():
  """Real executor: Python script with unicode resources."""
  script = models.Script(
      src=(
          "with open('references/unicode.txt', 'r', encoding='utf-8') as f:"
          " print(f.read())"
      )
  )
  skill = _make_skill_with_script("test_skill", "unicode.py", script)
  skill.resources.get_reference.side_effect = lambda n: (
      "你好，世界" if n == "unicode.txt" else None
  )
  skill.resources.list_references.return_value = ["unicode.txt"]
  toolset = _make_real_executor_toolset([skill])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "test_skill",
          "file_path": "unicode.py",
      },
      tool_context=ctx,
  )
  assert "status" in result, f"Result missing status: {result}"
  assert result["status"] == "success"
  assert "你好，世界" in result["stdout"]


@pytest.mark.asyncio
async def test_integration_python_imports_sibling_script_module():
  """Real executor: Python scripts can import helpers from scripts/."""
  skill = _make_skill_with_scripts(
      "test_skill",
      {
          "run.py": models.Script(
              src="from helper import message\nprint(message())"
          ),
          "helper.py": models.Script(
              src="def message():\n  return 'hello from helper'"
          ),
      },
  )
  toolset = _make_real_executor_toolset([skill])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "test_skill",
          "file_path": "run.py",
      },
      tool_context=ctx,
  )
  assert "status" in result, f"Result missing status: {result}"
  assert result["status"] == "success"
  assert result["stdout"] == "hello from helper\n"
  assert result["stderr"] == ""


@pytest.mark.asyncio
async def test_integration_python_sys_exit_zero():
  """Real executor: sys.exit(0) is treated as success."""
  script = models.Script(src="import sys; sys.exit(0)")
  skill = _make_skill_with_script("test_skill", "exit_zero.py", script)
  toolset = _make_real_executor_toolset([skill])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "test_skill",
          "file_path": "exit_zero.py",
      },
      tool_context=ctx,
  )
  assert "status" in result, f"Result missing status: {result}"
  assert result["status"] == "success"


@pytest.mark.asyncio
async def test_integration_shell_stdout_and_stderr():
  """Real executor: shell script preserves both stdout and stderr."""
  script = models.Script(src="echo output; echo warning >&2")
  skill = _make_skill_with_script("test_skill", "both.sh", script)
  toolset = _make_real_executor_toolset([skill])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "test_skill",
          "file_path": "both.sh",
      },
      tool_context=ctx,
  )
  assert "status" in result, f"Result missing status: {result}"
  assert result["status"] == "warning"
  assert "output" in result["stdout"]
  assert "warning" in result["stderr"]


@pytest.mark.asyncio
async def test_integration_shell_stderr_only():
  """Real executor: shell script with only stderr reports error."""
  script = models.Script(src="echo failure >&2")
  skill = _make_skill_with_script("test_skill", "err.sh", script)
  toolset = _make_real_executor_toolset([skill])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "test_skill",
          "file_path": "err.sh",
      },
      tool_context=ctx,
  )
  assert "status" in result, f"Result missing status: {result}"
  assert result["status"] == "error"
  assert "failure" in result["stderr"]


# ── Shell JSON envelope parsing (unit tests with mock executor) ──


@pytest.mark.asyncio
async def test_shell_json_envelope_parsed(mock_skill1):
  """Shell JSON envelope is correctly unpacked by run_async."""
  import json

  envelope = json.dumps({
      "__shell_result__": True,
      "stdout": "hello from shell\n",
      "stderr": "",
      "returncode": 0,
  })
  executor = _make_mock_executor(stdout=envelope)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["status"] == "success"
  assert result["stdout"] == "hello from shell\n"
  assert result["stderr"] == ""


@pytest.mark.asyncio
async def test_shell_json_envelope_nonzero_returncode(mock_skill1):
  """Non-zero returncode in shell envelope sets stderr."""
  import json

  envelope = json.dumps({
      "__shell_result__": True,
      "stdout": "",
      "stderr": "",
      "returncode": 2,
  })
  executor = _make_mock_executor(stdout=envelope)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["status"] == "error"
  assert "Exit code 2" in result["stderr"]


@pytest.mark.asyncio
async def test_shell_json_envelope_with_stderr(mock_skill1):
  """Shell envelope with both stdout and stderr reports warning."""
  import json

  envelope = json.dumps({
      "__shell_result__": True,
      "stdout": "data\n",
      "stderr": "deprecation warning\n",
      "returncode": 0,
  })
  executor = _make_mock_executor(stdout=envelope)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["status"] == "warning"
  assert result["stdout"] == "data\n"
  assert result["stderr"] == "deprecation warning\n"


@pytest.mark.asyncio
async def test_shell_json_envelope_timeout(mock_skill1):
  """Shell envelope from TimeoutExpired reports error status."""
  import json

  envelope = json.dumps({
      "__shell_result__": True,
      "stdout": "partial output\n",
      "stderr": "Timed out after 300s",
      "returncode": -1,
  })
  executor = _make_mock_executor(stdout=envelope)
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["status"] == "error"
  assert result["stdout"] == "partial output\n"
  assert "Timed out" in result["stderr"]


@pytest.mark.asyncio
async def test_shell_non_json_stdout_passthrough(mock_skill1):
  """Non-JSON shell stdout is passed through without parsing."""
  executor = _make_mock_executor(stdout="plain text output\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={"skill_name": "skill1", "file_path": "setup.sh"},
      tool_context=ctx,
  )
  assert result["status"] == "success"
  assert result["stdout"] == "plain text output\n"


# ── input_files packaging ──


@pytest.mark.asyncio
async def test_execute_script_input_files_packaged(mock_skill1):
  """Verify references, assets, and scripts are packaged inside the wrapper code."""
  executor = _make_mock_executor(stdout="ok\n")
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  await tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=ctx,
  )

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]

  # input_files is no longer populated; it's serialized inside the script
  assert code_input.input_files is None or len(code_input.input_files) == 0

  # Ensure the extracted literal contains our fake files
  assert "references/ref1.md" in code_input.code
  assert "assets/asset1.txt" in code_input.code
  assert "scripts/setup.sh" in code_input.code
  assert "scripts/run.py" in code_input.code
  assert "scripts/build.rb" in code_input.code

  # Verify content mappings exist in the string
  assert "'references/ref1.md': 'ref content 1'" in code_input.code
  assert "'assets/asset1.txt': 'asset content 1'" in code_input.code


# ── Integration: shell non-zero exit ──


@pytest.mark.asyncio
async def test_integration_shell_nonzero_exit():
  """Real executor: shell script with non-zero exit via JSON envelope."""
  script = models.Script(src="exit 42")
  skill = _make_skill_with_script("test_skill", "fail.sh", script)
  toolset = _make_real_executor_toolset([skill])
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "test_skill",
          "file_path": "fail.sh",
      },
      tool_context=ctx,
  )
  assert "status" in result, f"Result missing status: {result}"
  assert result["status"] == "error"
  assert "42" in result["stderr"]


# ── Finding 1: system instruction references correct tool name ──


def test_system_instruction_references_run_skill_script():
  """System instruction must reference the actual tool name."""
  assert "run_skill_script" in skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION
  assert (
      "execute_skill_script"
      not in skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION
  )


def test_system_instruction_marks_load_skill_as_non_terminal():
  """Rule 7 must tell the model load_skill does not complete the turn.

  Without it, some models (notably Gemini) treat the load_skill tool call as
  the entire turn and stop with no visible output, producing empty responses.
  """
  instruction = skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION
  assert "does NOT complete your turn" in instruction
  assert "empty response" in instruction


def test_prefixed_system_instruction_includes_continue_after_load_rule():
  """The prefixed builder variant must also carry rule 7 (with the prefix)."""
  instruction = skill_toolset._build_skill_system_instruction(prefix="my")
  assert "does NOT complete your turn" in instruction
  assert "my_load_skill" in instruction


# ── Finding 2: empty files are mounted (not silently dropped) ──


@pytest.mark.asyncio
async def test_execute_script_empty_files_mounted():
  """Verify empty files are included in wrapper code, not dropped."""
  skill = mock.create_autospec(models.Skill, instance=True)
  skill.name = "skill_empty"
  skill.resources = mock.MagicMock(
      spec=[
          "get_reference",
          "get_asset",
          "get_script",
          "list_references",
          "list_assets",
          "list_scripts",
      ]
  )
  skill.resources.get_reference.side_effect = (
      lambda n: "" if n == "empty.md" else None
  )
  skill.resources.get_asset.side_effect = (
      lambda n: "" if n == "empty.cfg" else None
  )
  skill.resources.get_script.side_effect = (
      lambda n: models.Script(src="") if n == "run.py" else None
  )
  skill.resources.list_references.return_value = ["empty.md"]
  skill.resources.list_assets.return_value = ["empty.cfg"]
  skill.resources.list_scripts.return_value = ["run.py"]

  executor = _make_mock_executor(stdout="ok\n")
  toolset = skill_toolset.SkillToolset([skill], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  await tool.run_async(
      args={"skill_name": "skill_empty", "file_path": "run.py"},
      tool_context=ctx,
  )

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  assert "'references/empty.md': ''" in code_input.code
  assert "'assets/empty.cfg': ''" in code_input.code
  assert "'scripts/run.py': ''" in code_input.code


# ── Finding 3: invalid args type returns clear error ──


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "bad_args",
    [
        "not a dict",
        42,
        True,
    ],
)
async def test_execute_script_invalid_args_type(mock_skill1, bad_args):
  """Non-dict args should return INVALID_ARGS_TYPE, not crash."""
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "run.py",
          "args": bad_args,
      },
      tool_context=ctx,
  )
  assert result["error_code"] == "INVALID_ARGUMENTS"
  executor.execute_code.assert_not_called()


@pytest.mark.parametrize(
    "bad_short_options",
    [
        "not a dict",
        42,
        True,
        ["list"],
    ],
)
@pytest.mark.asyncio
async def test_execute_script_invalid_short_options_type(
    mock_skill1, bad_short_options
):
  """Non-dict short_options should return INVALID_SHORT_OPTIONS_TYPE, not crash."""
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "run.py",
          "short_options": bad_short_options,
      },
      tool_context=ctx,
  )
  assert result["error_code"] == "INVALID_ARGUMENTS"
  executor.execute_code.assert_not_called()


@pytest.mark.parametrize(
    "bad_positional_args",
    [
        "not a list",
        42,
        True,
        {"dict": 1},
    ],
)
@pytest.mark.asyncio
async def test_execute_script_invalid_positional_args_type(
    mock_skill1, bad_positional_args
):
  """Non-list positional_args should return INVALID_POSITIONAL_ARGS_TYPE, not crash."""
  executor = _make_mock_executor()
  toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  result = await tool.run_async(
      args={
          "skill_name": "skill1",
          "file_path": "run.py",
          "positional_args": bad_positional_args,
      },
      tool_context=ctx,
  )
  assert result["error_code"] == "INVALID_ARGUMENTS"
  executor.execute_code.assert_not_called()


# ── Finding 4: binary file content is handled in wrapper ──


@pytest.mark.asyncio
async def test_execute_script_binary_content_packaged():
  """Verify binary asset content uses 'wb' mode in wrapper code."""
  skill = mock.create_autospec(models.Skill, instance=True)
  skill.name = "skill_bin"
  skill.resources = mock.MagicMock(
      spec=[
          "get_reference",
          "get_asset",
          "get_script",
          "list_references",
          "list_assets",
          "list_scripts",
      ]
  )
  skill.resources.get_reference.side_effect = (
      lambda n: b"\x00\x01\x02" if n == "data.bin" else None
  )
  skill.resources.get_asset.return_value = None
  skill.resources.get_script.side_effect = lambda n: (
      models.Script(src="print('ok')") if n == "run.py" else None
  )
  skill.resources.list_references.return_value = ["data.bin"]
  skill.resources.list_assets.return_value = []
  skill.resources.list_scripts.return_value = ["run.py"]

  executor = _make_mock_executor(stdout="ok\n")
  toolset = skill_toolset.SkillToolset([skill], code_executor=executor)
  tool = skill_toolset.RunSkillScriptTool(toolset)
  ctx = _make_tool_context_with_agent()
  await tool.run_async(
      args={"skill_name": "skill_bin", "file_path": "run.py"},
      tool_context=ctx,
  )

  call_args = executor.execute_code.call_args
  code_input = call_args[0][1]
  # Binary content should appear as bytes literal
  assert "b'\\x00\\x01\\x02'" in code_input.code
  # Wrapper code handles binary with 'wb' mode
  assert "'wb' if isinstance(content, bytes)" in code_input.code


@pytest.mark.asyncio
async def test_skill_toolset_dynamic_tool_resolution(mock_skill1, mock_skill2):
  # Set up skills with additional_tools in metadata
  mock_skill1.frontmatter.metadata = {
      "adk_additional_tools": ["my_custom_tool", "my_func", "shared_tool"]
  }
  mock_skill1.name = "skill1"

  mock_skill2.frontmatter.metadata = {
      "adk_additional_tools": [
          "skill2_tool",
          "shared_tool",
          "prefixed_mock_tool",
      ]
  }
  mock_skill2.name = "skill2"

  # Prepare additional tools
  custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
  custom_tool.name = "my_custom_tool"

  skill2_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
  skill2_tool.name = "skill2_tool"

  shared_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
  shared_tool.name = "shared_tool"

  def my_func():
    """My function description."""
    pass

  # Setup prefixed toolset
  mock_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
  mock_tool.name = "prefixed_mock_tool"
  prefixed_set = mock.create_autospec(skill_toolset.BaseToolset, instance=True)
  prefixed_set.get_tools_with_prefix.return_value = [mock_tool]

  toolset = skill_toolset.SkillToolset(
      [mock_skill1, mock_skill2],
      additional_tools=[
          custom_tool,
          skill2_tool,
          shared_tool,
          my_func,
          prefixed_set,
      ],
  )

  ctx = _make_tool_context_with_agent()
  ctx.invocation_id = "turn-1"
  # Initial tools (only core)
  tools1 = await toolset.get_tools_with_prefix(readonly_context=ctx)
  assert len(tools1) == 4

  # Activate skills
  load_tool = skill_toolset.LoadSkillTool(toolset)
  await load_tool.run_async(args={"skill_name": "skill1"}, tool_context=ctx)
  await load_tool.run_async(args={"skill_name": "skill2"}, tool_context=ctx)

  # Dynamic tools should now be resolved
  ctx.invocation_id = "turn-2"
  tools = await toolset.get_tools_with_prefix(readonly_context=ctx)
  assert tools is not tools1
  tool_names = {t.name for t in tools}

  # Core tools
  assert "list_skills" in tool_names
  assert "load_skill" in tool_names
  assert "load_skill_resource" in tool_names
  assert "run_skill_script" in tool_names

  # Skill 1 tools
  assert "my_custom_tool" in tool_names
  assert "my_func" in tool_names

  # Skill 2 tools
  assert "skill2_tool" in tool_names

  # Shared tool (should only appear once)
  assert "shared_tool" in tool_names
  assert len([t for t in tools if t.name == "shared_tool"]) == 1

  # Prefixed toolset tool
  assert "prefixed_mock_tool" in tool_names

  # Check specific tool resolution details
  my_func_tool = next(t for t in tools if t.name == "my_func")
  assert isinstance(my_func_tool, skill_toolset.FunctionTool)
  assert my_func_tool.description == "My function description."


@pytest.mark.asyncio
async def test_skill_toolset_resolution_error_handling(mock_skill1, caplog):
  mock_skill1.frontmatter.metadata = {
      "adk_additional_tools": ["nonexistent_tool"]
  }
  mock_skill1.name = "skill1"
  toolset = skill_toolset.SkillToolset([mock_skill1])
  ctx = _make_tool_context_with_agent()

  # Activate skill
  load_tool = skill_toolset.LoadSkillTool(toolset)
  await load_tool.run_async(args={"skill_name": "skill1"}, tool_context=ctx)

  with caplog.at_level(logging.WARNING):
    tools = await toolset.get_tools(readonly_context=ctx)

  # Should still return basic skill tools
  assert len(tools) == 4


@pytest.fixture(name="mock_registry")
def _mock_registry():
  """Fixture for mock SkillRegistry."""
  registry = mock.create_autospec(skill_toolset.SkillRegistry, instance=True)
  registry.search_tool_description.return_value = None
  return registry


@pytest.mark.asyncio
async def test_skill_toolset_init_with_registry(mock_registry):
  # Verify toolset initializes with empty skills list and registers SearchSkillsTool
  toolset = skill_toolset.SkillToolset(registry=mock_registry)
  assert toolset._registry == mock_registry
  assert len(toolset._skills) == 0

  tools = await toolset.get_tools()
  assert len(tools) == 5
  assert isinstance(tools[4], skill_toolset.SearchSkillsTool)


def test_search_skills_tool_init_without_registry():
  toolset = skill_toolset.SkillToolset()
  with pytest.raises(
      ValueError,
      match="SearchSkillsTool requires a configured skill registry.",
  ):
    skill_toolset.SearchSkillsTool(toolset)


@pytest.mark.asyncio
async def test_search_skills_tool_run_async(
    mock_registry, mock_skill1, tool_context_instance
):
  # Verify search_skills tool works, filters out local naming conflicts
  mock_frontmatter1 = mock.create_autospec(models.Frontmatter, instance=True)
  mock_frontmatter1.name = "skill1"
  mock_frontmatter1.model_dump.return_value = {"name": "skill1"}

  mock_frontmatter2 = mock.create_autospec(models.Frontmatter, instance=True)
  mock_frontmatter2.name = "skill2"
  mock_frontmatter2.model_dump.return_value = {"name": "skill2"}

  mock_registry.search_skills.return_value = [
      mock_frontmatter1,
      mock_frontmatter2,
  ]

  # skill1 exists locally, skill2 does not
  toolset = skill_toolset.SkillToolset([mock_skill1], registry=mock_registry)
  tool = skill_toolset.SearchSkillsTool(toolset)

  result = await tool.run_async(
      args={"query": "test"}, tool_context=tool_context_instance
  )

  mock_registry.search_skills.assert_called_once_with(query="test")
  # skill1 should be filtered out due to naming conflict with local mock_skill1
  assert result == [{"name": "skill2"}]


@pytest.mark.asyncio
async def test_load_skill_tool_fallback_to_registry(
    mock_registry, mock_skill1, tool_context_instance
):
  # Verify LoadSkillTool falls back to registry, fetching on-demand without cache
  mock_registry.get_skill.return_value = mock_skill1

  toolset = skill_toolset.SkillToolset(registry=mock_registry)
  tool = skill_toolset.LoadSkillTool(toolset)

  # Mock state
  state_key = "_adk_activated_skill_test_agent"
  tool_context_instance.state.get.return_value = None

  # First load: goes to registry
  tool_context_instance.invocation_id = "inv-1"
  result = await tool.run_async(
      args={"skill_name": "skill1"}, tool_context=tool_context_instance
  )

  assert result["skill_name"] == "skill1"
  assert result["instructions"] == "instructions for skill1"
  mock_registry.get_skill.assert_called_once_with(name="skill1")

  # Verify that the Skill frontmatter was cached in the unified state dictionary
  tool_context_instance.state.__setitem__.assert_called_once_with(
      state_key, ["skill1"]
  )

  # Mock state.get to return the cached skill list
  tool_context_instance.state.get.side_effect = lambda key, default=None: (
      ["skill1"] if key == state_key else default
  )

  # Second load on a new turn: should fetch from registry on-demand as only frontmatter is in state
  tool_context_instance.invocation_id = "inv-2"
  mock_registry.get_skill.reset_mock()
  result2 = await tool.run_async(
      args={"skill_name": "skill1"}, tool_context=tool_context_instance
  )
  assert result2["skill_name"] == "skill1"
  mock_registry.get_skill.assert_called_once_with(name="skill1")


@pytest.mark.asyncio
async def test_registry_skill_resources_and_tools_resolved(
    mock_registry, tool_context_instance
):
  # Create a mock registry skill that declares local additional tools
  mock_skill = mock.create_autospec(models.Skill, instance=True)
  mock_skill.name = "registry_skill"
  mock_skill.instructions = "registry instructions"
  mock_skill.frontmatter = mock.create_autospec(
      models.Frontmatter, instance=True
  )
  mock_skill.frontmatter.name = "registry_skill"
  mock_skill.frontmatter.metadata = {"adk_additional_tools": ["my_custom_tool"]}

  mock_skill.resources = mock.MagicMock()
  mock_skill.resources.get_reference.return_value = "reference content"

  mock_registry.get_skill.return_value = mock_skill

  # Setup toolset with the registry and the local implementation of the tool
  custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
  custom_tool.name = "my_custom_tool"

  toolset = skill_toolset.SkillToolset(
      registry=mock_registry, additional_tools=[custom_tool]
  )

  # Load the skill via LoadSkillTool
  load_tool = skill_toolset.LoadSkillTool(toolset)

  state_key = "_adk_activated_skill_test_agent"
  tool_context_instance.state.get.side_effect = lambda key, default=None: (
      ["registry_skill"] if key == state_key else default
  )

  result = await load_tool.run_async(
      args={"skill_name": "registry_skill"}, tool_context=tool_context_instance
  )
  assert result["skill_name"] == "registry_skill"

  # 1. Verify dynamic tools from the registry are resolved
  tools = await toolset.get_tools(readonly_context=tool_context_instance)
  tool_names = {t.name for t in tools}
  assert "my_custom_tool" in tool_names

  # 2. Verify resource loading resolves registry skill correctly
  resource_tool = skill_toolset.LoadSkillResourceTool(toolset)
  res_result = await resource_tool.run_async(
      args={
          "skill_name": "registry_skill",
          "file_path": "references/ref.md",
      },
      tool_context=tool_context_instance,
  )
  assert res_result["content"] == "reference content"


@pytest.mark.asyncio
async def test_process_llm_request_with_registry(
    mock_registry, tool_context_instance
):
  toolset = skill_toolset.SkillToolset(registry=mock_registry)
  llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True)

  await toolset.process_llm_request(
      tool_context=tool_context_instance, llm_request=llm_req
  )

  llm_req.append_instructions.assert_called_once()
  args, _ = llm_req.append_instructions.call_args
  instructions = args[0]
  assert len(instructions) == 2
  assert instructions[0] == skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION
  assert "search_skills" in instructions[1]


@pytest.mark.asyncio
async def test_turn_scoped_skill_cache(
    mock_registry, mock_skill1, tool_context_instance
):
  # Verify that multiple tool calls on the same registry-provided skill in the same turn
  # use the turn-scoped cache and only call the registry once.
  mock_registry.get_skill.return_value = mock_skill1

  toolset = skill_toolset.SkillToolset(registry=mock_registry)
  load_tool = skill_toolset.LoadSkillTool(toolset)
  script_tool = skill_toolset.RunSkillScriptTool(toolset)

  tool_context_instance.invocation_id = "same-turn-id"
  tool_context_instance.state.get.return_value = None

  # Call LoadSkillTool
  res1 = await load_tool.run_async(
      args={"skill_name": "skill1"}, tool_context=tool_context_instance
  )
  assert res1["skill_name"] == "skill1"
  mock_registry.get_skill.assert_called_once_with(name="skill1")

  # Setup executor for script tool
  executor = mock.create_autospec(skill_toolset.BaseCodeExecutor, instance=True)
  executor.execute_code.return_value = mock.MagicMock(
      stdout="hello\n", stderr=""
  )
  toolset._code_executor = executor

  # Call RunSkillScriptTool in the same turn
  res2 = await script_tool.run_async(
      args={"skill_name": "skill1", "file_path": "run.py"},
      tool_context=tool_context_instance,
  )
  assert res2["status"] == "success"
  # Registry should NOT be called again
  mock_registry.get_skill.assert_called_once_with(name="skill1")


@pytest.mark.asyncio
async def test_turn_scoped_skill_cache_eviction(mock_registry, mock_skill1):
  mock_registry.get_skill.return_value = mock_skill1
  toolset = skill_toolset.SkillToolset(registry=mock_registry)

  # Fill cache up to limit
  for i in range(16):
    await toolset._get_or_fetch_skill("skill1", f"turn-{i}")

  assert len(toolset._fetched_skill_cache) == 16
  assert "turn-0" in toolset._fetched_skill_cache

  # Next turn should evict oldest (turn-0)
  await toolset._get_or_fetch_skill("skill1", "turn-16")
  assert len(toolset._fetched_skill_cache) == 16
  assert "turn-0" not in toolset._fetched_skill_cache
  assert "turn-1" in toolset._fetched_skill_cache


@pytest.mark.asyncio
async def test_turn_scoped_skill_cache_concurrency(mock_registry, mock_skill1):
  # Delay registry fetch to simulate async I/O and force race condition
  async def delayed_get_skill(name):
    await asyncio.sleep(0.1)
    return mock_skill1

  mock_registry.get_skill.side_effect = delayed_get_skill
  toolset = skill_toolset.SkillToolset(registry=mock_registry)

  # Trigger concurrent calls for the same skill in the same turn
  results = await asyncio.gather(
      toolset._get_or_fetch_skill("skill1", "concurrent-turn"),
      toolset._get_or_fetch_skill("skill1", "concurrent-turn"),
      toolset._get_or_fetch_skill("skill1", "concurrent-turn"),
  )

  for res in results:
    assert res is mock_skill1

  # Registry should have been called exactly once
  mock_registry.get_skill.assert_called_once_with(name="skill1")


def test_skill_toolset_disables_invocation_cache():
  """Verify SkillToolset disables tool invocation caching to allow dynamic tools."""
  toolset = skill_toolset.SkillToolset()
  assert toolset._use_invocation_cache is False


@pytest.mark.asyncio
async def test_close_cancels_futures_and_clears_cache():
  # pylint: disable=protected-access
  toolset = skill_toolset.SkillToolset()

  # Create mock futures for testing close() behavior
  loop = asyncio.get_running_loop()
  fut1 = loop.create_future()
  fut2 = loop.create_future()
  fut2.set_result(None)  # Already done future

  toolset._fetched_skill_cache = collections.OrderedDict(
      {
          "turn1": {
              "skill1": fut1,
              "skill2": fut2,
          }
      }
  )

  await toolset.close()

  assert fut1.cancelled()
  assert not fut2.cancelled()  # Done futures shouldn't/can't be cancelled
  assert not toolset._fetched_skill_cache


@pytest.mark.asyncio
async def test_process_llm_request_with_tool_name_prefix(
    mock_skill1, mock_skill2, tool_context_instance, mock_registry
):
  toolset = skill_toolset.SkillToolset(
      [mock_skill1, mock_skill2],
      registry=mock_registry,
      tool_name_prefix="my_prefix",
  )

  # Manually remove ListSkillsTool from self._tools to simulate it not being available
  # so that instructions[1] is generated with available_skills
  toolset._tools = [
      t
      for t in toolset._tools
      if not isinstance(t, skill_toolset.ListSkillsTool)
  ]

  llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True)

  await toolset.process_llm_request(
      tool_context=tool_context_instance, llm_request=llm_req
  )

  llm_req.append_instructions.assert_called_once()
  args, _ = llm_req.append_instructions.call_args
  instructions = args[0]
  assert len(instructions) == 3
  assert "`my_prefix_load_skill`" in instructions[0]
  assert "`my_prefix_load_skill_resource`" in instructions[0]
  assert "`my_prefix_run_skill_script`" in instructions[0]
  assert "my_prefix_search_skills" in instructions[2]


@pytest.mark.asyncio
async def test_skill_toolset_with_list_tool_filter():
  toolset = skill_toolset.SkillToolset(
      tool_filter=["list_skills", "load_skill"]
  )
  tools = await toolset.get_tools()
  tool_names = [t.name for t in tools]
  assert "list_skills" in tool_names
  assert "load_skill" in tool_names
  assert "load_skill_resource" not in tool_names
  assert "run_skill_script" not in tool_names


@pytest.mark.asyncio
async def test_skill_toolset_with_predicate_tool_filter():
  # Filter to only tools containing 'resource' in their name
  toolset = skill_toolset.SkillToolset(
      tool_filter=lambda tool, ctx=None: "resource" in tool.name
  )
  tools = await toolset.get_tools()
  tool_names = [t.name for t in tools]
  assert tool_names == ["load_skill_resource"]


@pytest.mark.asyncio
async def test_skill_toolset_with_dynamic_tools_filter(
    mock_skill1, tool_context_instance
):
  mock_skill1.frontmatter.metadata = {
      "adk_additional_tools": ["my_custom_tool"]
  }

  custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
  custom_tool.name = "my_custom_tool"

  toolset = skill_toolset.SkillToolset(
      skills=[mock_skill1],
      additional_tools=[custom_tool],
      tool_filter=["list_skills", "my_custom_tool"],
  )

  state_key = "_adk_activated_skill_test_agent"
  tool_context_instance.state.get.side_effect = lambda key, default=None: (
      ["skill1"] if key == state_key else default
  )

  tools = await toolset.get_tools(readonly_context=tool_context_instance)
  tool_names = [t.name for t in tools]
  assert "list_skills" in tool_names
  assert "my_custom_tool" in tool_names
  assert "load_skill" not in tool_names
